[2026-09-27] eagle: personal/plans/music-artist-albumartist-repair.md personal/plans/tagwriter-container.md personal/tech/id3-tag-repair.md

This commit is contained in:
Alexey Martemyanov committed 2026-09-27 12:53:10 +06:00
1 parent 8ce2b1e6a4
commit 6912c41874
3 files changed
+294 -5

No files matched your search

+174
View File
@@ -141,6 +141,180 @@ os.replace(TMP, SRC)
с combining `U+0306`. Перед сравнением/группировкой всегда
`unicodedata.normalize("NFC", name)`.
## 🖊 Слой ЗАПИСИ — ID3v1 vs ID3v2 (добавлено 2026-09-27)
Зеркало правил W1–W15 скилла. Полные формулировки — в `id3-tag-repair/SKILL.md`.
### 🔴 W1 — «управляемый» фрейм, который писатель не умеет СОЗДАТЬ = гарантированная потеря
Если `TRCK` стоит в списке на удаление (`_managed_frame_ids()`), но ветки его создания
в `_frames()` нет — `delall` выполнится, `add` никогда. **Номер трека исчезнет у всех файлов,
и источника для восстановления нет** (колонки `track` в плане тоже не было).
`delall` + отсутствующий `add` **не даёт ошибки**: apply рапортует успех, verify — `MISMATCH=0`.
```python
assert sorted(_managed_frame_ids()) == sorted(fid for _, fid, _ in FIELD_MAP)
```
### 🔴 W2 — из трёх способов `save()` два уничтожают аудио
```python
tags = ID3(path); tags.save(path, v2_version=3) # ✅ в ТОТ ЖЕ путь
m = MP3(path); m["TIT2"] = ...; m.save() # ✅ контейнер-осознанный
tags = ID3(path); tags.save("/tmp/out.mp3"); os.replace(...) # 🔴 АУДИО СТЁРТО
```
`ID3()` — парсер **тег-блока**, не файла: `save(other_path)` сериализует только фреймы.
Файлы 2–10 МБ → ~1 КБ. Поймано на 40 файлах, отчёт был «40/40 записано, 0 ошибок».
### 🔴 W3 — ID3-писатель по не-MP3 разрушает контейнер
`ID3()` на `.m4a`/`.wma` → `ID3NoHeaderError` → `except: tags = ID3()` → запись ID3 **поверх**
контейнера: первые байты `49 44 33` вместо `ftyp`/ASF → `moov atom not found`. Непоправимо
(в `backup_raw/` те же байты — битые до сессии). Испорчено 16 файлов. Фильтровать `.mp3`.
### 🔴 W4 — `delall` ВНЕ проверки «значение не пусто»
```python
# ❌ «очистить поле» ничего не делает при пустом новом значении
if (r.get("new_artist") or "").strip(): out.append(TPE1(...))
for fr in _frames(r): tags.delall(...); tags.add(fr) # пустой список → цикл не идёт
# ✅ удаление безусловно, запись условна
for fid in _managed_frame_ids(): tags.delall(fid)
for fr in _frames(r): tags.add(fr)
```
### 🔴 W5 — ID3v1 невидим для `ID3()` и переживает `save()`, перекрывая v2
`ID3()` читает **только** v2-блок в начале. ID3v1 — отдельная запись 128 байт в **конце**
(`TAG` + title/artist/album по 30 + year 4 + comment 30 + genre 1). `save()` пишет v2 и
**v1 не трогает**. В библиотеке — **1099 из 2004 файлов** с v1, её payload — latin-1 с обрезкой:
```
v1 title = '????? ????????? ??????? (?????' ← кириллица уничтожена
v2 TIT2 = 'Танец маленьких лебедей (Лебединое озеро)'
v1 title = 'J.S. Bach: Toccata and Fugue (' ← обрезано на 30 байтах
```
Плеер, читающий v1 первым, показывает мусор. **Фикс: `save(path, v1=0, v2_version=3)`.**
### 🔴 W6 — probe инварианта обязан проходить собственные валидаторы
```python
probe = {k: "x" for k, _, _ in FIELD_MAP} # 🔴 "x" невалиден для номера трека
# normalize_track("x") -> "" -> TRCK не создаётся -> ЛОЖНЫЙ ABORT
```
Ложный ABORT отключают — и защита исчезает. Для числовых полей probe = `"1"`.
### 🔴 W7 — граница «из CSV» / «из файла» проходит по КОЛОНКАМ CSV
```
колонки CSV → значение ТОЛЬКО из CSV. Пусто ⇒ УДАЛИТЬ тег. НЕ подставлять старое.
фреймы вне CSV → из ID3v2; если там нет — из ID3v1
ID3v1 → удалить после записи
```
ID3v1 хранит 6 полей; **4 дублируют управляемые колонки** и игнорируются. Переносить стоит
только **year → TDRC** и **comment → COMM**, и лишь когда в v2 их нет.
### 🔴 W8 — год в v1 бывает бинарным мусором
Реальное значение: `year = '\x00\x00\x000'`. Без валидации уедет в `TDRC` как есть.
Чистить NUL/пробелы **с двух сторон**, отбрасывать управляющие символы,
требовать `re.fullmatch(r"\d{4}", year)`.
### 🔴 W9 — нормализатору нужны тесты на ведущие нули
`^[1-9]\d{0,3}$` выглядит разумно и **молча отвергает `095`** → 133 файла получили бы
**пустой** TRCK. Отбрасывать также `0`, `03/`, `/10`, `N/M` с нулём, спам-строки
(в колонке трека был найден `P@X@Tu*HCK*u`). Верно: `^\d{1,4}(/\d{1,4})?$` + `int()`.
### 🔴 W10–W13 — процесс
| # | Правило |
|---|---|
| **W10** | Скрипт-фиксер НЕ должен перезаписывать файл, который сам же правит — повторный прогон затрёт правки. Разделять: CSV-нормализатор + точечные `patch`. |
| **W11** | Тест обязан **импортировать** код, а не повторять его — копия `normalize_track()` продолжала проходить после фикса (2 ложных FAIL). |
| **W12** | Образ содержит **свою** копию скрипта: `docker cp` в смонтированный каталог не обновляет `/app/script.py` внутри образа. Сигнатура: `AttributeError: no attribute '_read_id3v1'` после успешной заливки. |
| **W13** | Договорённость только в чате **не существует** — вердикт Alex: «это неоднократно». Флаг, колонка, решение → в файл **в том же ходе**. |
### 🔴 W14 — MD5 всего файла бесполезен для поиска дубликатов; хешировать АУДИО
Две копии трека в разных папках: размер одинаков, **MD5 файла разный** (блоки тегов разные —
у одной есть `TRCK`). Хешировать аудио-поток: пропустить v2-блок в начале, отрезать v1 в конце.
```python
def audio_hash(path):
size = os.path.getsize(path)
with open(path, "rb") as f:
head = f.read(10); start = 0
if head[:3] == b"ID3": # пропуск v2 (synchsafe size)
s = ((head[6] & 0x7F) << 21) | ((head[7] & 0x7F) << 14) | \
((head[8] & 0x7F) << 7) | (head[9] & 0x7F)
start = 10 + s
end = size
f.seek(size - 128)
if f.read(3) == b"TAG": end = size - 128 # отрезать v1
f.seek(start); h, rem = hashlib.md5(), end - start
while rem > 0:
chunk = f.read(min(1 << 20, rem))
if not chunk: break
h.update(chunk); rem -= len(chunk)
return h.hexdigest()
```
Проверено: `ac0f10ba114e196f9e49b99913bb1c27` для **обеих** копий → истинный дубликат.
**Для плана это важно:** дубликат, получающий **разные** `album`/`genre` из плана — дефект плана.
### 🔴 W15 — аудит `artist` требует РУЧНОГО разбора по классам
Regex-детектор по `new_artist` дал 8 классов / 628 попаданий; **606 — ложные**. Реальных — два:
| Класс | Реально? | Пример |
|---|---|---|
| Исполнитель вместо композитора | ✅ | `'Maria Makarova (organ)'` на файле Баха → должно быть `Иоганн Себастьян Бах` |
| Название сборника в `artist` | ✅ | `'Сказочный сборник'` ×15, `'Классика в современной обработке'` ×3 |
| `artist` и `album` пересекаются | ❌ 204 | `artist='Винни-Пух'` + `album='Винни-Пух (2)'` — норма |
| Несколько имён в поле | ❌ 267 | `'Би-2 feat. Диана Арбенина'` — фит |
| Смешаны алфавиты | ❌ 96 | `'Иванушки International'`, `'Flёur'` — норма |
| `artist == title` | ❌ 25 | `'Винни-Пух.mp3'` внутри сборника «Винни-Пух» |
**Печатать значения по классу и смотреть глазами.** Счётчик класса — не список дефектов:
объявить 628 дефектов при 20 реальных = потеря доверия (реакция Alex: «ты нихуя не проверил csv»).
🔴 **Паттерн «инструмент в скобках» — самая ценная проверка:** `'(organ)'`, `'(piano)'`,
`'(vocals)'`, `'(guitar)'` в `artist` означает, что поле держит **исполнителя**; композитора
брать из title/имени файла. Это ровно тот дефект, из-за которого Jellyfin показывал мусор.
### Приёмка записи
`snapshot.py` ДО и ПОСЛЕ + diff. Критерий: управляемые фреймы совпадают с CSV, **каждый
неуправляемый фрейм на месте** (`APIC`, `TDRC`, `COMM`, `TCOM`, `TSSE`, `WCOP`, `WXXX`, `TKEY`,
`TCOP`…), `ID3v1` исчез, **размер в байтах не изменился** (равный размер = MPEG-поток не тронут).
Отчёт `ok=3 err=0` не доказывает ничего из этого.
### Запуск длинных задач без апрува и без сети
```bash
docker create --name tw <image> # без промпта
docker cp script.py tw:/app/script.py # АКТУАЛЬНЫЙ скрипт внутрь
docker start -a tw && docker rm -f tw # foreground, видно stdout
```
### 🔴 W16 — имена рабочих CSV: один боевой файл
Раскладка, к которой пришли (после путаницы `APPLY-PLAN-FINAL` vs `APPLY-PLAN-FINAL2`):
```
APPLY-PLAN.csv — БОЕВОЙ, единственный источник истины
APPLY-PLAN-RAW.csv — сырой (до нормализации), НЕ применять
archive/APPLY-PLAN-v0-*.csv — устаревшие версии
```
Суффикс `2` в имени файла-плана — источник ошибок: непонятно, какой применять. Один файл
без суффикса, остальные явно помечены. Перед переименованием — `tar czf` снимок каталога.
## ⚪ Что НЕ является багом
- **Рукописная транслитерация:** `КняZz`, `EMINЭM`, `Flёur`, `изBESTняк`, `Скаzки`, `Глюкоzа`,