[2026-09-26] eagle: family/how-to/jellyfin-config.md personal/index.md personal/plans/balda-mm-endtoend-plan.md personal/projects/balda/balda-kraken-mm-deploy.md personal/projects/balda/balda-mm-delegation-plan.md personal/tech/balda-go-fx-pitfalls.md personal/tech/music-tags-repair-truenas.md personal/tech/yandex-music-api.md personal/yandex-music-library.md
This commit is contained in:
1 parent
4109797f6b
commit
f9b55abb8c
9 files changed
+2190
-94
No files matched your search
@@ -1,6 +1,7 @@
|
||||
# Balda — Go/fx pitfalls и метод правок (зеркало скилла)
|
||||
|
||||
> Создано 2026-09-26. Обновлено 2026-09-26 (сессия реализации MM-хендлера, полный проход).
|
||||
> Создано 2026-09-26. Обновлено 2026-09-26 (сессия реализации MM-хендлера, полный проход +
|
||||
> **приёмка сквозного пути**, Фаза 3 закрыта).
|
||||
> Добавлены: **`bad_seq` — настоящая причина тишины WS** (§«WS-фрейм без seq»),
|
||||
> **`optional:"true"` скрывает отсутствующую зависимость** (§«Дефект 5»),
|
||||
> **`canonicalSubject` и неизвестный транспорт** (§«auth: subject-based»),
|
||||
@@ -9,6 +10,8 @@
|
||||
> **`FROM scratch` без CA** (§«scratch и TLS»),
|
||||
> **`principal` с префиксом в БД** (§«Дефект 7»),
|
||||
> **`state.db` читать вместе с WAL** (§«Метод: чтение sqlite-состояния»).
|
||||
> 🔴 **Новое в этой сессии (приёмка):** §«Приёмка агент-проверяемый» — как доказать ответ
|
||||
> бота без GUI; §«Mattermost Team Edition: что недоступно» — push невозможен.
|
||||
> Уточнён §«авторизация — ОДИН механизм»: челлендж НЕ был причиной №1, им был `bad_seq`.
|
||||
> Зеркало секции скилла `devops/docker-balda-service`, добавленной 2026-09-26.
|
||||
> Источник: работа над Mattermost-адаптером Балды (Фаза 2) + деплой на Kraken (Фаза 3).
|
||||
@@ -19,6 +22,62 @@
|
||||
|
||||
---
|
||||
|
||||
## ✅ Приёмка, проверяемая агентом (без GUI-браузера)
|
||||
|
||||
Требование Alex: «приёмка = видно в UI, не "контейнер Up" и не "лог said accepted"».
|
||||
Агент без браузера **не может** посмотреть UI — но может верифицировать ответ бота
|
||||
однозначно, читая **ленту канала** REST'ом и сверяя `user_id` автора:
|
||||
|
||||
```bash
|
||||
ssh kraken 'curl -s -H "Authorization: Bearer *** /home/kraken/balda/secrets/mm-bot-token)" \
|
||||
"http://localhost:8065/api/v4/channels/7bjw4g7o1jdsbmjbqd649xxgaw/posts?per_page=4" \
|
||||
| jq -r ".order[] as \$o | .posts[\$o] | \"\(.user_id) | \(.message)\""'
|
||||
```
|
||||
|
||||
Ожидаемый вид — пост человека и **пост с `user_id` = bot_user_id**:
|
||||
```
|
||||
c6udeze8dfrdxm5jbgq55bd58r | @balda reply with a single word
|
||||
edxrgupoxiyg5gzmr79tjypbme | работает ← это и есть доказательство
|
||||
```
|
||||
|
||||
📌 Это заменяет «видно в UI»: `user_id` автора поста — однозначный факт, не интерпретация.
|
||||
⚠️ `post_user_id` в логах ингресса **не** подходит: там id отправителя запроса, не автора ответа.
|
||||
⚠️ Не отчитываться «готово» по факту `delivery settled` в логах — доставляется ли пост, видно
|
||||
только из ленты.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Mattermost Team Edition: что недоступно (проверено)
|
||||
|
||||
`mattermost version` → `Build Enterprise Ready: false`. На TE **невозможно** включить push
|
||||
в официальных мобильных приложениях — это архитектура продукта, не настройка.
|
||||
|
||||
| Что | Факт |
|
||||
|---|---|
|
||||
| **Тумблер push** | Живёт в `EmailSettings.SendPushNotifications`, **не** в `ServiceSettings` — `mmctl config get ServiceSettings.EnablePushNotifications` даёт `Error: invalid key` |
|
||||
| **Официальный push-прокси** | Жив (HTTP 200), но для TE-`DiagnosticId` отвечает `Did not send message because of missing platform property` — APNs/FCM-сертификаты только у лицензированных EE |
|
||||
| **Свой MPNS** | Требует **собственные подписанные сборки** мобильных приложений + Apple Developer ($99/год) + FCM. Официальные клиенты прошиты на bundle ID Mattermost Inc. и к своему MPNS не подключатся |
|
||||
| **SMTP-почта** | Единственный путь без лицензии. `SendEmailNotifications: true` уже стоит, но SMTP не настроен (`localhost:10025`, на хосте ни одного слушателя) |
|
||||
|
||||
🔴 **Проба прокси — читать ТЕЛО, не статус.** `curl -X POST https://push.mattermost.com/api/v1/send_push`
|
||||
возвращает **HTTP 200** даже на невалидный `server_id`; решение в теле: `{"status":"FAIL","error":"…"}`.
|
||||
HTTP-код здесь — ложный сигнал успеха.
|
||||
|
||||
⚠️ `DiagnosticId` ищется так (таблица **`systems`**, не `Systems`; колонка `ServerId` отсутствует):
|
||||
```bash
|
||||
docker exec mm-postgres psql -U mmuser -d mattermost -t -c \
|
||||
"SELECT name, value FROM systems WHERE name ILIKE '%diagnostic%';"
|
||||
```
|
||||
🔴 **Кавычки внутри `psql -c` в ssh-строке ломаются**: `name IN ('DiagnosticId')` уходит в
|
||||
shell до psql → Postgres видит `column "DiagnosticId" does not exist`. Экранировать `'"'"'`
|
||||
либо использовать `ILIKE` с шаблоном без значения в кавычках.
|
||||
|
||||
📌 MM-стек на Kraken: `/home/kraken/mattermost/docker-compose.yml`, 3 сервиса
|
||||
(`postgres` → `mm-postgres`, `mattermost`, сеть `mm-net` → `mattermost_mm-net`).
|
||||
БД: `docker exec mm-postgres psql -U mmuser -d mattermost`.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 `go build` = exit 0 ≠ рабочий транспорт
|
||||
|
||||
Компилятор не видит ошибок DI-графа. `go build ./...` проходит, а приложение падает
|
||||
@@ -623,8 +682,268 @@ conn.WriteJSON(map[string]any{ // ← 🔴 а эт
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Дефект 6: точка входа онбординга, закрытая проверкой доступа
|
||||
|
||||
Найдено 2026-09-26. `/start owner=<token>` в DM получил от бота
|
||||
**«You are not allowed to use Balda in this channel.»**
|
||||
|
||||
**Причина:** `HandleCommand` проверял доступ **до** обработки, а владельца ещё нет — тупик:
|
||||
привязаться нельзя, потому что не авторизован; авторизоваться нельзя, потому что не привязан.
|
||||
Владелец в canonical store привязывается **этой же командой**, поэтому она обязана быть
|
||||
доступна неавторизованному.
|
||||
|
||||
**Неправильно:**
|
||||
```go
|
||||
if !h.canAccess(ctx, cmd.SenderID) {
|
||||
return h.sendPlain(ctx, cmd.Locator, accessDeniedText)
|
||||
}
|
||||
```
|
||||
|
||||
**Правильно** (образец — `zulip_ingress.go:172`):
|
||||
```go
|
||||
if cmd.Command != commandStart && !h.canAccess(ctx, cmd.SenderID) {
|
||||
return h.sendPlain(ctx, cmd.Locator, accessDeniedText)
|
||||
}
|
||||
```
|
||||
|
||||
⚠️ `commandStart` **уже объявлена** в пакете `handlersfx` (приходит из telegram/zulip-веток).
|
||||
Своя константа даёт `commandStart redeclared in this block`. Использовать общую.
|
||||
|
||||
📌 **Правило для нового транспорта:** онбординг-команда — единственная, что доступна
|
||||
неавторизованному пользователю. Проверять это явно, иначе транспорт невозможно
|
||||
забутстрапить.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Дефект 7: `principal` с транспортным префиксом в `balda_user_bindings`
|
||||
|
||||
Найдено 2026-09-26. `/start owner=<token>` вернул успех («You are now registered as the bot
|
||||
owner»), но каждое обращение к боту оставалось `settlement_stage="unauthorized"`.
|
||||
|
||||
**Механизм.** Авторизация сверяет subject вида `<channel>:<principal>`. `canonicalSubject()`
|
||||
**разрезает** его по первому `:` и **отбрасывает** префикс:
|
||||
|
||||
```go
|
||||
if before, after, ok := strings.Cut(trimmed, ":"); ok {
|
||||
channelType = strings.ToLower(strings.TrimSpace(before))
|
||||
principal = strings.TrimSpace(after) // ← префикс отрезан
|
||||
}
|
||||
```
|
||||
|
||||
А `GetUserByBinding` делает **точное** сравнение:
|
||||
```go
|
||||
return s.getUser(ctx, `b.channel_type = ? AND b.principal = ?`, channelType, principal)
|
||||
```
|
||||
|
||||
Если в БД записался `principal = 'mattermost:<user_id>'` при `channel_type='mattermost'`,
|
||||
то поиск по `principal='<user_id>'` не находит **ничего** — молча, без ошибки.
|
||||
|
||||
🔴 **Правило:** в `balda_user_bindings` `principal` хранится **БЕЗ** транспортного префикса.
|
||||
Префикс живёт только в `channel_type`. Нельзя собирать subject тем же хелпером, которым
|
||||
потом нормализуешь: `userSubject()` в `start/handler.go` оборачивает principal в
|
||||
`<transport>:<principal>` — если на вход уже пришёл обёрнутый principal, выходит двойная
|
||||
обёртка и запись уезжает с префиксом.
|
||||
|
||||
**Диагностика — прочитать таблицу:**
|
||||
```bash
|
||||
sudo -n sqlite3 /srv/…/docker/balda/state/state.db \
|
||||
"SELECT channel_type, principal FROM balda_user_bindings;"
|
||||
# правильно: mattermost | c6udeze8dfrdxm5jbgq55bd58r
|
||||
# неправильно: mattermost | mattermost:c6udeze8dfrdxm5jbgq55bd58r
|
||||
```
|
||||
|
||||
**Лечение** (`tools/mm-fix-owner-principal.sh`: бэкап → стоп → UPDATE → старт):
|
||||
```sql
|
||||
UPDATE balda_user_bindings
|
||||
SET principal = substr(principal, length(channel_type) + 2)
|
||||
WHERE channel_type = 'mattermost' AND principal LIKE channel_type || ':%';
|
||||
```
|
||||
|
||||
⚠️ Штатного пути **нет**: `AttachBinding` отказывает при существующей привязке
|
||||
(`count != 0` → `ErrBindingAlreadyAssigned`), detach-API отсутствует вовсе. Правка БД —
|
||||
вынужденная мера, делать с бэкапом и **остановленным** контейнером (sqlite не любит
|
||||
конкурентной записи).
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Провайдер: `base_url` в YAML мёртв, работает только `OPENAI_BASE_URL`
|
||||
|
||||
Найдено 2026-09-26. Симптом (образ `0.1.7`):
|
||||
|
||||
```json
|
||||
{"component":"balda.turn_dispatcher",
|
||||
"error":"agent run: send openai request: Post \"https://api.openai.com/v1/chat/completions\":
|
||||
tls: failed to verify certificate: x509: certificate signed by unknown authority"}
|
||||
```
|
||||
|
||||
Запрос уходил на **`api.openai.com`**, хотя в `config.yaml` стоял
|
||||
`base_url: "https://api.deepseek.com/v1"`.
|
||||
|
||||
**Причина:** тип `LocalAPIConfig` в `github.com/normahq/runtime/v2@v2.0.11/agentconfig/config.go`
|
||||
имеет **только** `api_key` и `model`. Поля `base_url` в схеме **нет**:
|
||||
|
||||
```go
|
||||
type LocalAPIConfig struct {
|
||||
APIKey string `json:"api_key,omitempty" yaml:"api_key,omitempty" mapstructure:"api_key"`
|
||||
Model string `json:"model,omitempty" yaml:"model,omitempty" mapstructure:"model"`
|
||||
}
|
||||
```
|
||||
|
||||
Базовый URL читается **только из env** — `hostedagent/openai.go`:
|
||||
```go
|
||||
const defaultOpenAIBaseURL = "https://api.openai.com/v1"
|
||||
|
||||
func openAIBaseURL() string {
|
||||
if u := os.Getenv("OPENAI_BASE_URL"); u != "" {
|
||||
return strings.TrimRight(u, "/")
|
||||
}
|
||||
return defaultOpenAIBaseURL
|
||||
}
|
||||
```
|
||||
|
||||
📌 **Правило:** для любого OpenAI-совместимого провайдера (DeepSeek, локальные gateway и т.п.)
|
||||
базовый URL задавать **через `OPENAI_BASE_URL`** в `.env` или `environment:` compose.
|
||||
Ключ `base_url` в YAML **выглядит рабочим, но не делает ничего** — молчаливая ловушка.
|
||||
⚠️ Сама переменная попадает в процесс только если `env_file`/`environment` настроены.
|
||||
|
||||
**Фикс:** `OPENAI_BASE_URL=https://api.deepseek.com/v1` в `/home/kraken/balda/.env`
|
||||
(`tools/mm-add-base-url.sh`, идемпотентный, с бэкапом).
|
||||
|
||||
---
|
||||
|
||||
## 🔴 `FROM scratch` и TLS: нет доверенных корней → любой HTTPS падает
|
||||
|
||||
`Dockerfile.release` Балды собирается из `scratch`. В финальном образе **нет
|
||||
`/etc/ssl/certs`**, поэтому попытка установить любое TLS-соединение заканчивается:
|
||||
|
||||
```
|
||||
tls: failed to verify certificate: x509: certificate signed by unknown authority
|
||||
```
|
||||
|
||||
Ошибка **обманчива**: выглядит как «удалённый сервер с неверным сертификатом», а на деле
|
||||
в образе физически нет файла с корневыми CA — Go не может построить цепочку доверия.
|
||||
Симптом проявился ровно один раз, на первом исходящем HTTPS (запрос к LLM).
|
||||
|
||||
**Фикс — дельта `tools/deltas-mattermost-22-ca.json`:**
|
||||
```dockerfile
|
||||
FROM scratch
|
||||
|
||||
# CA bundle: the scratch base has no trust store, so any HTTPS call (the LLM
|
||||
# provider in particular) fails with 'x509: certificate signed by unknown
|
||||
# authority'. The builder stage is a Debian image and carries the bundle.
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
COPY --from=builder /out/balda /usr/local/bin/balda
|
||||
|
||||
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/balda"]
|
||||
```
|
||||
|
||||
📌 **Правило:** любой `scratch`/`distroless`-образ, делающий исходящие HTTPS-запросы, обязан
|
||||
содержать `ca-certificates.crt` **и** выставлять `SSL_CERT_FILE` (у distroless путь тот же).
|
||||
Копировать прямо из builder'а — он на Debian и бандл уже там.
|
||||
⚠️ Альтернатива для локального дебага: `SSL_CERT_FILE` с хоста через bind-mount. Для прода —
|
||||
копировать в образ.
|
||||
|
||||
---
|
||||
|
||||
## Метод: чтение sqlite-состояния Балды (образ `scratch`)
|
||||
|
||||
В образе **нет** ни `sh`, ни `sqlite3`, поэтому `docker exec` невозможен в принципе:
|
||||
|
||||
```
|
||||
OCI runtime exec failed: exec failed: unable to start container process:
|
||||
exec: "sh": executable file not found in $PATH
|
||||
```
|
||||
|
||||
**Рабочий порядок:**
|
||||
1. Файлы состояния: `<state_dir>/state.db` + `state.db-wal` + `state.db-shm`, владелец — **root**.
|
||||
2. `sqlite3` **есть на хосте** Kraken (`/usr/bin/sqlite3`), но не в контейнере.
|
||||
3. Для остановленного контейнера — прямой `sqlite3` под `sudo -n`.
|
||||
4. Для живого — `strings` (только просмотр, без записи).
|
||||
|
||||
🔴 **Читать `state.db` ВМЕСТЕ с `state.db-wal`.** Свежие записи живут в WAL, а основной файл
|
||||
выглядит пустым. Прямая потеря времени: таблица `balda_user_bindings` казалась пустой, хотя
|
||||
привязка владельца уже была записана — она лежала в `-wal`.
|
||||
|
||||
```bash
|
||||
STATE=/srv/…/docker/balda/state
|
||||
for f in state.db state.db-wal state.db-shm; do
|
||||
echo "--- $f"
|
||||
sudo -n strings "$STATE/$f" 2>/dev/null \
|
||||
| grep -oE "(telegram|mattermost|slackagent|zulip):[A-Za-z0-9_-]+" | sort -u
|
||||
done
|
||||
```
|
||||
|
||||
**Полезные ключи KV внутри `state.db`:**
|
||||
- `balda.appowner_auth_token` — owner auth token (32 символа), нужен для `/start owner=<token>`;
|
||||
- `balda_user_bindings` — привязки, `principal` **без** префикса (см. §Дефект 7);
|
||||
- `balda_user_binding_claims` — claim'ы, `generated_binding_count`, `role IN ('administrator','operator')`.
|
||||
|
||||
⚠️ `sudo` через ssh требует `< /dev/null`, иначе `Connection closed by remote host`.
|
||||
⚠️ Правка каталога данных (`sudo cp -a state <dir>`) уходит на апрув и **блокируется** по
|
||||
таймауту. Точечный `UPDATE` одного поля такого апрува не требует — предпочтителен.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Mattermost WS: `bad_seq` — пинг без `seq` убивает подписку молча
|
||||
|
||||
Найдено 2026-09-26 диагностическим логом каждого кадра (`deltas-mattermost-15-diag.json`).
|
||||
**Это и была настоящая причина «connected, но тишина»** — до неё ошибочно считалось, что
|
||||
виноват `authentication_challenge` (он тоже вреден, но лишь как усугубляющий фактор).
|
||||
|
||||
MM отвечал раз в 30 секунд, ровно на каждый пинг:
|
||||
```json
|
||||
{"status":"FAIL","error":{"id":"api.web_socket_router.bad_seq.app_error",
|
||||
"message":"Invalid sequence for WebSocket message."}}
|
||||
```
|
||||
|
||||
**Механизм:** Mattermost требует **монотонный `seq` в КАЖДОМ клиентском фрейме**. Балда слала
|
||||
`{"action":"ping"}` **без `seq`** → MM помечал соединение сломанным и **прекращал доставлять
|
||||
`posted`**, при этом **сокет не закрывал**. Поэтому `mattermost websocket connected` в логе
|
||||
остаётся, а событий нет. Особенно коварно после снятия челленджа: счётчик `seq` пропадал
|
||||
вместе с ним, и **ни один** исходящий фрейм его не нёс.
|
||||
|
||||
**Фикс** (`deltas-mattermost-16-seq.json`): поле `seq int64`, `nextSeq()` под мьютексом,
|
||||
единый `writeEvent(conn, action, data)`, сброс `seq = 0` при новом соединении, пинг через
|
||||
`writeEvent`, а фрейм со `status:FAIL` логируется **warn**, а не игнорируется.
|
||||
|
||||
📌 **Правило:** любая отправка в MM-сокет — **только** через `writeEvent`. «Сырой»
|
||||
`conn.WriteJSON`/`WriteMessage` без `seq` = тихо убитая подписка.
|
||||
📌 **Общий урок:** лог «connection established» не значит, что подписка жива. Проверять
|
||||
**фактом получения события**, а не фактом коннекта. Временный лог каждого кадра окупается
|
||||
сразу — и его стоит оставить после фикса.
|
||||
|
||||
---
|
||||
|
||||
## Связанные заметки
|
||||
|
||||
- [[personal/projects/balda/balda-mattermost-connector]] — Фаза 2, где всё это найдено
|
||||
- [[personal/projects/balda/balda-kraken-mm-deploy]] — Фаза 3, деплой на Kraken
|
||||
- [[personal/plans/balda-mm-endtoend-plan]] — сквозной путь `@balda` → ответ
|
||||
- [[personal/projects/balda/balda-mm-delegation-plan]] — общий план
|
||||
- [[personal/tech/hermes-secret-redaction-bypass]] — маскировка секретов в tool-выводе
|
||||
|
||||
---
|
||||
|
||||
## Диаграмма потока ингресса (для отладки нового транспорта)
|
||||
|
||||
```
|
||||
MM websocket frame
|
||||
↓ readLoop (frame.messageType == TextMessage?)
|
||||
↓ handleFrame ← лог «event decoded»; status:FAIL → warn
|
||||
↓ dispatchPosted (IsBotEcho / IsSystemPost / IsDeletedPost → drop с логом)
|
||||
↓ processPosted (MentionsBot / StripMention → drop с логом)
|
||||
↓ InboundProcessor.ProcessInbound ← 🔴 ТУТ был nil (Дефект 5)
|
||||
↓ ingressapp.Service.Process
|
||||
├─ Authorizer.Authorize ← subject + canonicalSubject (Дефекты 3, 7)
|
||||
├─ SessionPreparer.Prepare ← GetSession → RestoreSession → EnsureSession
|
||||
└─ Dispatcher.Dispatch ← durable envelope
|
||||
↓ turn_dispatcher → provider (OPENAI_BASE_URL + CA, Дефект F)
|
||||
```
|
||||
|
||||
**Точки отказа, которые надо проверять по порядку** — сверху вниз, по логам:
|
||||
`frame received` → `event decoded` → `posted event accepted` → `post accepted for processing`
|
||||
→ `conversational ingress settled` (`settlement_stage`!) → `assembled provider user content`
|
||||
→ `turn task failed`. Пока нет строки `assembled provider user content` — провайдер не виноват.
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Music Tags Repair — TrueNAS 7890 файлов
|
||||
title: Music Tags Repair — TrueNAS 7906 файлов
|
||||
created: '2026-09-26'
|
||||
updated: '2026-09-26'
|
||||
type: tech
|
||||
@@ -11,13 +11,15 @@ tags:
|
||||
- metadata
|
||||
- id3
|
||||
- repair
|
||||
- encoding
|
||||
related:
|
||||
- '[[family/how-to/jellyfin-config]]'
|
||||
- '[[family/projects/media-toolbox-taiga]]'
|
||||
---
|
||||
# Music Tags Repair — TrueNAS (7890 файлов)
|
||||
# Music Tags Repair — TrueNAS (7906 файлов)
|
||||
|
||||
> **Статус: ЭТАП 1 В РАБОТЕ (инвентаризация).** Правки тегов НЕ начаты. План согласован Alex 2026-09-26.
|
||||
> **Статус: ЭТАПЫ 1–2 ЗАВЕРШЕНЫ (2026-09-26). CSV передан Alex на проверку. Правки файлов НЕ начаты.**
|
||||
> Следующий шаг — апрув Alex по CSV, затем бэкап + применение.
|
||||
|
||||
## Контекст
|
||||
|
||||
@@ -108,7 +110,63 @@ ssh truenas_admin@mallexxx.duckdns.org \
|
||||
| `!Детские` | 570 | 🔴 mojibake + пустые artist/album |
|
||||
| `z Другое, Enigma, Ленинград` | 1039 | ⚪ У части файлов тегов НЕТ вообще |
|
||||
|
||||
**Итого 7890 файлов.** Точные цифры появятся из CSV-инвентаря (этап 1).
|
||||
## ✅ РЕЗУЛЬТАТ ЭТАПА 1: инвентаризация завершена (2026-09-26)
|
||||
|
||||
**Просканировано 7906 файлов** (не 7890 — уточнённое число после полного обхода; 13 мин).
|
||||
Точное распределение вердиктов:
|
||||
|
||||
| Вердикт | Файлов | Что значит |
|
||||
|---|---:|---|
|
||||
| `partial` | 2919 | Часть тегов ок, часть пустая/мусор |
|
||||
| `mojibake` | 2419 | 🔧 **Восстановимо точно** |
|
||||
| `empty` | 1010 | Тегов нет — брать из имён путей |
|
||||
| `lost` | 662 | 🔴 **Данные уничтожены** (`\ufffd`) |
|
||||
| `clean` | 537 | ✅ Уже хорошо |
|
||||
| `probe_failed` | 359 | Разобрано отдельно (см. ниже) |
|
||||
|
||||
### 🔑 Подтверждение обратимости mojibake — 98.3%
|
||||
|
||||
Проверено на реальных данных, **не на догадке**. Примеры:
|
||||
|
||||
```
|
||||
Àëåøêà æàðèë íà áàÿíå → Алешка жарил на баяне
|
||||
Âëàäèìèð Âûñîöêèé → Владимир Высоцкий
|
||||
Íåèçâåñòíûé èñïîëíèòåëü → Неизвестный исполнитель
|
||||
Ãîëóáîé ùåíîê (2) → Голубой щенок (2)
|
||||
```
|
||||
|
||||
**2377 из 2419 (98.3%) конвертируются без потерь.** Остальные 42 — требуют ручного просмотра.
|
||||
|
||||
### `probe_failed` расшифрован (359 файлов)
|
||||
|
||||
| Что | Кол-во | Деталь |
|
||||
|---|---:|---|
|
||||
| Валидный **ID3v2.3** | 343 | Заголовок целый (`hdr_ok=True`), теги есть — `ffprobe` их не отдал по иной причине |
|
||||
| **RealMedia** (`00051607`) | 15 | Это не mp3 — `video/x-pn-realvideo` |
|
||||
| Без заголовка | 1 | — |
|
||||
|
||||
> Вывод: `probe_failed` ≠ «файл битый». 343 файла структурно валидны, но остаются
|
||||
> без извлечённых тегов. **Не блокируют ремонт остальных** — разбираются отдельно.
|
||||
|
||||
### Защита от порчи: доля кириллицы в `fix_encoding`
|
||||
|
||||
**Критичная находка.** Наивное `s.encode('latin-1').decode('cp1251')` **портит корректный
|
||||
UTF-8**: `Camille Saint-Saëns` → `Camille Saint-Saлns` (буква `ë` = U+00EB попадает в диапазон
|
||||
Latin-1, преобразование проходит и разрушает текст).
|
||||
|
||||
Рабочий guard — требовать, чтобы результат был **преимущественно кириллическим**:
|
||||
|
||||
```python
|
||||
letters = [c for c in out if c.isalpha()]
|
||||
cyr = sum(1 for c in letters if 0x0400 <= ord(c) <= 0x04FF)
|
||||
if cyr / len(letters) < 0.5: # не преимущественно кириллица
|
||||
return None # не трогать
|
||||
if cyr < 2: # одна буква — совпадение
|
||||
return None
|
||||
```
|
||||
|
||||
Проверено тестами: `Björk`, `Noël`, `Saint-Saëns`, `Kevin MacLeod`, `Рок-н-ролл` — **не трогаются**.
|
||||
|
||||
|
||||
## Проблема дублей-папок в `!Детские/Сказки/` (Alex проверит сам)
|
||||
|
||||
@@ -128,10 +186,10 @@ ssh truenas_admin@mallexxx.duckdns.org \
|
||||
|
||||
| # | Шаг | Статус |
|
||||
|---|---|---|
|
||||
| 1 | **Инвентаризация** read-only: `ffprobe` по 7890 файлам → CSV `путь, теги, классификация` | 🔄 **В РАБОТЕ** |
|
||||
| 2 | **Отдать CSV Alex** на проверку — полный путь + текущее содержимое тегов + предлагаемое исправление | ⏳ |
|
||||
| 3 | **Апрув Alex по CSV** → только после этого правки | ⏳ |
|
||||
| 4 | **Бэкап** — копировать **только перезаписываемые** файлы (не всю библиотеку) в `Music/backup/` | ⏳ |
|
||||
| 1 | **Инвентаризация** read-only: `ffprobe` по 7906 файлам → CSV `путь, теги, классификация` | ✅ **ГОТОВО** — [[#✅ РЕЗУЛЬТАТ ЭТАПА 1 инвентаризация завершена (2026-09-26)]] |
|
||||
| 2 | **CSV с предлагаемым исправлением** → Alex | ✅ **ГОТОВО** — `~/Downloads/music-tags/tag-fix-plan.csv` |
|
||||
| 3 | **Апрув Alex по CSV** → только после этого правки | ⏳ **ЖДЁМ** |
|
||||
| 4 | **Бэкап** — копировать **только перезаписываемые** файлы в `Music/backup/` | ⏳ |
|
||||
| 5 | **Ремонт** — перекодировка mojibake + восстановление из имён путей + чистка мусора | ⏳ |
|
||||
| 6 | **Приёмка** — Alex слушает выборку | ⏳ |
|
||||
|
||||
@@ -142,15 +200,17 @@ ssh truenas_admin@mallexxx.duckdns.org \
|
||||
- ✅ Чинить теги **везде**, чистить мусор, восстанавливать из названий где необходимо.
|
||||
- ✅ **Предварительная проверка Alex по CSV** с полным путём и текущим содержимым — обязательна до правок.
|
||||
|
||||
### ❓ НЕЗАКРЫТЫЙ ВОПРОС: расположение бэкапа
|
||||
### ✅ РЕШЕНО (2026-09-26): расположение бэкапа — `Music/backup/`
|
||||
|
||||
`Music/backup/` окажется **внутри** библиотеки Jellyfin → Jellyfin просканирует копии как дубли.
|
||||
Варианты:
|
||||
**Ответ Alex:** бэкап класть в `Music/backup/` (вариант 1), **снести его после валидации**
|
||||
и **до добавления библиотеки в Jellyfin**. Отдельное исключение `backup` в настройках
|
||||
библиотеки тогда не нужны — папка к моменту создания библиотеки уже удалена.
|
||||
|
||||
1. `Music/backup/` + исключить `backup` в настройках библиотеки Jellyfin
|
||||
2. `/mnt/RED_2TB/storage/music-backup/` — вне библиотеки, без доп. настроек
|
||||
Порядок получается: ремонт → валидация Alex → `rm -rf Music/backup/` → создание Music-библиотеки.
|
||||
|
||||
**Ждём ответа Alex.** Склонность — вариант 1 (как он просил) + исключение.
|
||||
> ⚠️ Скрипты ремонта уже **пропускают папку `backup`** при обходе
|
||||
> (`dirnames[:] = [d for d in dirnames if d != "backup"]`) — иначе повторный прогон
|
||||
> инвентаризации/ремонта захватит копии.
|
||||
|
||||
## Риски
|
||||
|
||||
@@ -160,19 +220,67 @@ ssh truenas_admin@mallexxx.duckdns.org \
|
||||
|
||||
## Артефакты
|
||||
|
||||
| Файл | Где | Что |
|
||||
Все скрипты — в `/Users/admin/Developer/personal-os/tools/music-tags/` (Mac).
|
||||
|
||||
| Файл | Что делает |
|
||||
|---|---|
|
||||
| `tag_inventory.py` | **Этап 1.** Обход `Music`, `ffprobe` по каждому файлу, классификация тегов (`clean`/`mojibake`/`lost`/`empty`/`partial`/`probe_failed`) → CSV. Путь к `ffprobe` через `FFPROBE` env (по умолчанию `ffprobe`) |
|
||||
| `analyse_inventory.py` | Анализ CSV инвентаря: доли вердиктов, проверка обратимости mojibake, разбор `lost`/`probe_failed` |
|
||||
| `build_fix_plan.py` | **Этап 2.** Строит CSV с колонкой «предлагаемое исправление». Содержит `fix_encoding`, `clean_value`, `repair`, `split_artist_title`, `title_from_name`, `derive_from_path` |
|
||||
| `check_fix_plan.py` | Контроль качества плана: сколько U+FFFD на выходе (должно быть 0), сколько без title, сэмплы «до/после» по каждой категории |
|
||||
| `find_bad_titles.py` | Ищет недоработки: `TrackNN` в title, голые номера, пустой artist |
|
||||
| `test_encoding_guard.py` | **32 регресс-теста.** Защита от порчи корректного UTF-8 (`Björk`, `Saint-Saëns`, `Noël`) + проверка `split_artist_title` |
|
||||
|
||||
| Артефакт | Где | Что |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| CSV-инвентарь | `/mnt/RED_2TB/storage/tmp/tag-inventory.csv` (NAS) → `~/Downloads/music-tags/tag-inventory.csv` (Mac) | 7906 строк, результат этапа 1 |
|
||||
| **CSV-план правок** | `~/Downloads/music-tags/tag-fix-plan.csv` (Mac) | **Отдан Alex 2026-09-26.** 4601 `fix` / 2946 `no_change` / 359 `probe_failed` |
|
||||
| Копия БД Jellyfin | `/tmp/jf-copy.db` (Mac) | Для чтения API-ключа (см. `[[family/how-to/jellyfin-config]]`) |
|
||||
|
||||
### Колонки `tag-fix-plan.csv`
|
||||
|
||||
`path | current_verdict | cur_title | cur_artist | cur_album | new_title | new_artist | new_album | action | source`
|
||||
|
||||
Последняя колонка `source` показывает, **откуда** взято значение — `title<-filename`,
|
||||
`artist<-folder`, `artist<-filename`, `title<-folder+tracknum`, `translit artist tag; folder is Cyrillic`.
|
||||
По ней видно, что ничего не выдумано.
|
||||
|
||||
### Логика восстановления (этап 2, итоговая)
|
||||
|
||||
| Приём | Пример |
|
||||
|---|---|
|
||||
| mojibake → оригинал | `Àëåøêà æàðèë íà áàÿíå` → `Алешка жарил на баяне` |
|
||||
| Пустое → из пути | `!Барды/Владимир Высоцкий/Чужая колея.mp3` → artist=`Владимир Высоцкий` |
|
||||
| `Artist - Title` из имени файла | `БИ-2 - Серебро.mp3` → artist=`БИ-2`, title=`Серебро` |
|
||||
| Мусор вычищен | `[muzmo.ru]` из artist/album |
|
||||
| `TrackNN` отвергнут | `Track07` → `Трое из Простоквашино 7` |
|
||||
| `\ufffd` → из имён | 662 файла |
|
||||
|
||||
**Источники, которые НЕ выдумываются:** если данных нет ни в теге, ни в пути —
|
||||
значение остаётся пустым/как было. 15 файлов с номером вместо названия (`007`, `48`, `12`)
|
||||
оставлены как есть — это реальные названия либо отсутствие источника.
|
||||
|
||||
## Питфоллы, найденные в этой сессии
|
||||
|
||||
- 🔴 **`python3` НЕТ в контейнере `jellyfin`** — для скриптов поднимать отдельный `python:3-alpine`.
|
||||
- 🔴 **`ffprobe` НЕТ на хосте TrueNAS** — только внутри контейнеров.
|
||||
- 🔴 **`ffprobe` НЕТ на хосте TrueNAS** — только внутри контейнеров (в `jellyfin` путь `/usr/lib/jellyfin-ffmpeg/ffprobe`).
|
||||
- 🔴 **`sed -i` на удалённом хосте применять нельзя** (нарушение правил Alex). Правильно: правка локально → `scp` заново. Именно так и было исправлено (`FFPROBE` → `os.environ.get`).
|
||||
- ⚠️ **Кириллица в inline `ssh`-командах** поднимает tirith `confusable_text` (HIGH). Команда проходит только через approval Alex. Митигация: пути с кириллицей собирать в файле-скрипте, а не в командной строке.
|
||||
- ⚠️ `alpine` без `ffmpeg` → инвентарь падает молча. Ставить `apk add --no-cache ffmpeg` первой строкой.
|
||||
- 🔴 **`execute_code` требует апрува** — Alex явно сказал: **«Без аппрува команды используй! Скрипты пиши»**. Писать исполняемое в файл (`write_file`) → запускать через `terminal`. Не инлайн.
|
||||
- 🔴 **Наивная перекодировка CP1251→Latin-1 портит корректный UTF-8** (`Saint-Saëns` → `Saint-Saлns`). Обязателен guard по доле кириллицы (см. выше).
|
||||
|
||||
### 🐞 Три бага, пойманные ДО выдачи CSV (файлы не пострадали)
|
||||
|
||||
| # | Баг | Как пойман | Фикс |
|
||||
|---|---|---|---|
|
||||
| 1 | Название категории (`!Барды`, `!Детские`) попадало в `artist` вместо исполнителя | `check_fix_plan.py`, сэмплы | Множество `CATEGORY_DIRS` — категорийный уровень отбрасывается при разборе пути |
|
||||
| 2 | `Camille Saint-Saëns` → `Camille Saint-Saлns` — перекодировка ломала корректный UTF-8 | `check_fix_plan.py`, сэмплы «partial» | Guard по доле кириллицы в результате (≥50%, ≥2 буквы) |
|
||||
| 3 | Управляющие байты (`\x1d`) в ID3 «чинились» вместо пометки | Просмотр сэмплов | `CONTROL_RE` → такие значения отвергаются, берётся имя файла |
|
||||
|
||||
> 🔑 **Методический вывод:** контроль качества плана (`check_fix_plan.py`) на сэмплах
|
||||
> «до/после» поймал все три бага. Без этого CSV ушёл бы Alex с испорченными предложениями.
|
||||
> **Всегда проверять вывод скрипта на сэмплах до передачи пользователю.**
|
||||
|
||||
## Связанные заметки
|
||||
|
||||
|
||||
@@ -156,8 +156,9 @@ curl -s -H "$H" "https://api.music.yandex.net/account/status" | jq -r '.result.a
|
||||
|
||||
**8 плейлистов Яндекс:** Классика (3), ДР (3), ok (4), олдскуд (3), Слушать позже (34), Best (1), Мои аудиозаписи ВКонтакте (237), Мегаpolice - АК-47 (20)
|
||||
|
||||
### Открытая задача
|
||||
Выгрузить 956 лайков + 8 плейлистов + 100 альбомов в `personal/yandex-music-library.md` по образцу [[personal/spotify-library]]. **Не сделано — ждёт подтверждения.**
|
||||
### Статус (2026-09-26)
|
||||
- **Яндекс — ГОТОВО:** `personal/yandex-music-library.md` — 956 лайков (с датами), 8 плейлистов (321 трек), 100 альбомов. 1456 строк, 117 KB. Добавлено в `personal/index.md`.
|
||||
- **Spotify — блокер:** `~/.hermes/auth.json` → `.providers.spotify` содержит client_id + refresh_token, но токен **отозван** (`invalid_grant: Refresh token revoked`) → `SpotifyAuthRequiredError`. Нужен `hermes auth spotify` (интерактивно, браузер).
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in new issue
Block a user