diff --git a/personal/plans/music-artist-albumartist-repair.md b/personal/plans/music-artist-albumartist-repair.md index 5484454f..e44a06a3 100755 --- a/personal/plans/music-artist-albumartist-repair.md +++ b/personal/plans/music-artist-albumartist-repair.md @@ -4,7 +4,7 @@ created: '2026-09-27' updated: '2026-09-27' type: plan namespace: personal -status: script-v13-id3v1-logic-tested-awaiting-go +status: csv-audited-20-artist-defects-awaiting-alex tags: - music - metadata @@ -23,6 +23,27 @@ related: # Music `artist` + `albumartist` Repair — правила Alex и ход работ +> ## 🔴 ИМЕНА CSV ПЕРЕИМЕНОВАНЫ (2026-09-27) — актуальная раскладка +> +> Все упоминания `APPLY-PLAN-FINAL.csv` / `APPLY-PLAN-FINAL2.csv` **ниже по тексту устарели**. +> Актуально (в `~/milestones/music-csv-2026-09-26/` и `~/Downloads/music-tags/`): +> +> | Было | Стало | Роль | +> |---|---|---| +> | `APPLY-PLAN-FINAL2.csv` | **`APPLY-PLAN.csv`** | ✅ **БОЕВОЙ, единственный источник истины** (7861 стр, 16 колонок, md5 `245efb55ab1de26cb5172f0754192c3d`) | +> | `APPLY-PLAN-FINAL.csv` | `APPLY-PLAN-RAW.csv` | сырой план (track не нормализован) — **НЕ ПРИМЕНЯТЬ** | +> | `APPLY-PLAN.csv` (12:02) | `archive/APPLY-PLAN-v0-20260927-1202.csv` | устаревший (нет колонки `track`) | +> | `APPLY-PLAN2.csv` (12:03) | `archive/APPLY-PLAN-v0-20260927-1203.csv` | устаревший | +> +> Снимок каталога перед переименованием: `~/milestones/music-csv-BEFORE-RENAME-20260927-124542.tar.gz`. +> +> ## 🔴 АУДИТ `new_artist` ВЫЯВИЛ 20 ДЕФЕКТОВ — ПРАВКА ЖДЁТ ALEX +> +> `'Maria Makarova (organ)'` ×2 (исполнитель вместо композитора — должен быть **Бах**), +> `'Классика в современной обработке'` ×3, `'Сказочный сборник'` ×15. +> Подробно: [[personal/plans/tagwriter-container]] § 3.5. +> **Боевой прогон заблокирован до правки этих 20 файлов.** + **Задача Alex (2026-09-27):** > «Артиста всем трём где он не указан или указан неправильно, включая детские песни ставишь > в реального артиста. Что не знаешь — ищешь в интернете. Для _всех_ треков. diff --git a/personal/plans/tagwriter-container.md b/personal/plans/tagwriter-container.md index 7c3577a8..0cca4e0f 100755 --- a/personal/plans/tagwriter-container.md +++ b/personal/plans/tagwriter-container.md @@ -4,7 +4,7 @@ created: '2026-09-26' updated: '2026-09-27' type: plan namespace: personal -status: v13-csv-renamed-single-source-of-truth-awaiting-go +status: v14-csv-audited-20-defects-awaiting-alex tags: - music - truenas @@ -19,11 +19,12 @@ related: --- # tagwriter — применение тегов к MP3 (TrueNAS) -> 🔴 **СТАТУС: скрипт готов и протестирован. БОЕВОЙ ПРОГОН НЕ ЗАПУЩЕН — ЖДЁТ «go» от Alex.** +> 🔴 **СТАТУС: скрипт готов и протестирован. Аудит CSV выявил 20 дефектов в `new_artist` +> (§ 3.5) — правка НЕ начата, ждёт решения Alex. БОЕВОЙ ПРОГОН НЕ ЗАПУЩЕН.** ## 1. Что делает скрипт -Применяет `APPLY-PLAN-FINAL2.csv` (7861 строка) к MP3 на `/storage/Music`. +Применяет `APPLY-PLAN.csv` (7861 строка) к MP3 на `/storage/Music`. Пишет через `mutagen`, **только ID3v2.3-блок** — MPEG-поток не пересжимается. ### Логика полей — граница по КОЛОНКАМ CSV @@ -119,6 +120,83 @@ changed=no: 213 MD5: `245efb55ab1de26cb5172f0754192c3d` — сверен между Mac и NAS. +## 3.5 🔴 АУДИТ CSV — 5 классов дефектов (2026-09-27) + +Alex: «**ты нихуя не проверил csv. давай бэкапь ее и проверяй всю.**» +Бэкап снят: `APPLY-PLAN.csv.bak-chk-20260927-124918`. + +Скрипт аудита: `~/Automation/music-tags/audit_artist.py` (8 классов A–H). +Результат — **2 реальных класса дефектов, 20 файлов**; остальные 6 классов оказались +ложными срабатываниями детектора. + +### A. 🔴 Исполнитель вместо композитора — 2 файла + +``` +'Maria Makarova (organ)' Бах — Toccata and Fugue in D minor.mp3 ×2 +``` +**Мария Макарова — органистка, это ИСПОЛНИТЕЛЬ.** Должно быть `Иоганн Себастьян Бах`. +Это ровно тот кейс, из-за которого в прошлой сессии искали источник бага Jellyfin #18162 — +тогда нашли `Maria Makarova (organ)` в теге и не поправили. + +### B. 🔴 Название сборника в поле артиста — 18 файлов + +``` +'Классика в современной обработке' Моцарт — Турецкий марш.mp3 ×3 → по композитору из title +'Сказочный сборник' Чебурашка, Котёнок Гав, Айболит, Щелкунчик … ×15 +``` +`'Сказочный сборник'` — не артист вообще. Для сказок нужно согласованное значение +(кандидаты: `Сказки` — как `albumartist`; пусто; автор конкретной сказки). + +### C / D — чисто ✓ + +``` +служебные значения (Various/Unknown/NA/…): 0 +URL/спам в артисте: 0 +``` + +### E, F, G, H — ЛОЖНЫЕ срабатывания (CSV корректен) + +| класс | n | почему НЕ дефект | +|---|---:|---| +| E «артист и альбом пересекаются» | 204 | `artist='Винни-Пух'` + `album='Винни-Пух (2)'` — норма | +| F «несколько артистов в одном поле» | 267 | фиты: `'Би-2 feat. Диана Арбенина'` — норма | +| G «смешаны алфавиты» | 96 | `'Иванушки International'`, `'Flёur'` — норма | +| H «artist == title» | 25 | `'Винни-Пух.mp3'` в сборнике «Винни-Пух» — норма | + +🔴 **Правило: детектор по regex даёт много ложных срабатываний. Каждый класс разбирать +по примерам вручную, прежде чем объявлять дефектом.** E/F/G/H — шум, править нельзя. + +### Попутно: дубликаты файлов + +`Моцарт — Турецкий марш.mp3` лежит в **двух** папках (`!Классика` и +`z Другое, Enigma, Ленинград/Классика`). Проверено `cmp_audio.py` (хеш аудио без тег-блока): + +``` +размер: 5993976 = 5993976 +ID3v2-блок: 87040 = 87040 +аудио-MD5: ac0f10ba114e196f9e49b99913bb1c27 (одинаков) +→ АУДИО ИДЕНТИЧНО — это дубликат +``` +Файлы различались только наличием `TRCK` в v2 (у одного 3 фрейма, у другого 4). +План даёт им **разные** `album`/`genre` — для дубликата это дефект плана. + +### Скрипт `cmp_audio.py` — сравнение аудио в обход тегов + +Считает MD5 от аудио-потока: пропускает ID3v2-блок (`10 + synchsafe size`), +отрезает ID3v1 (последние 128 байт при `TAG`). Отвечает на вопрос «это дубликат +или разные записи», когда размеры совпадают, а MD5 файла различается. + +## ⏳ ОТКРЫТЫЙ ВОПРОС: 20 файлов ждут решения Alex + +| # | Что | Предложено | +|---|---|---| +| 1 | `Maria Makarova (organ)` ×2 | `Иоганн Себастьян Бах` (или `Бах`) | +| 2 | `Классика в современной обработке` ×3 | по композитору из title: Моцарт ×2, Вивальди ×1 | +| 3 | `Сказочный сборник` ×15 | `Сказки` / пусто / автор сказки — **нужно решение** | +| 4 | `title='Вивальди А. - Зима (рок-обработка)'` | срезать префикс `Вивальди А. - `? | + +Правка боевого CSV **не начата** — ждёт ответа по всем четырём пунктам. + ## 4. Что скрипт НЕ трогает `APIC` (обложка) · `TDRC` (год) · `TCOM` · `TXXX` · `USLT` · `TPUB` · `TLEN` · @@ -215,13 +293,29 @@ docker start -a tw && docker rm -f tw | # | Шаг | Статус | |---|---|---| -| 1 | Боевой `--apply --verify` по 7861 | 🔴 **ЖДЁТ «go»** | +| 0 | 🔴 **Аудит `new_artist` → 20 дефектов, правка CSV** | ⏳ **ЖДЁТ решения Alex (4 вопроса в § 3.5)** | +| 1 | Боевой `--apply --verify` по 7861 | 🔴 **ЖДЁТ «go»** (после шага 0) | | 2 | Приёмка Alex в UI (Navidrome/Jellyfin) | ⏳ | | 3 | Откат 40 из `backup_conv40/` | ⏳ | | 4 | Удаление 29 битых/DRM ([[personal/tech/music-broken-toload]]) | ⏳ | | 5 | Удаление бэкапов (`backup/` `backup_raw/` `backup_fix/` `backup_conv40/`) | ⏳ после приёмки | | 6 | Lidarr + Navidrome | ⏳ не начато | +### Артефакты этой сессии + +| Файл | Назначение | +|---|---| +| `~/Automation/music-tags/apply_tags_v2.py` | писатель тегов (566 стр, ID3v1-логика) | +| `~/Automation/music-tags/normalize_track_csv.py` | вход `APPLY-PLAN-RAW.csv` → выход `APPLY-PLAN.csv` | +| `~/Automation/music-tags/audit_artist.py` | аудит 8 классов дефектов в CSV | +| `~/Automation/music-tags/test_logic.py` | тесты нормализатора и инвариантов | +| `~/Automation/music-tags/snapshot.py` | снимок фреймов файла (ДО/ПОСЛЕ) | +| `~/Automation/music-tags/cmp_audio.py` | сравнение аудио-потоков (поиск дубликатов) | +| `~/Automation/music-tags/dump_v1.py` | дамп ID3v1 + ID3v2 для сравнения | +| `~/Automation/music-tags/find_v1.py` | поиск mp3 с ID3v1 | +| `~/Automation/music-tags/Dockerfile.v2` | python:3.11-slim + mutagen 1.47.0 | +| `~/Downloads/music-tags/tagwriter/rename_apply_plan.py` | раскладка имён CSV (одноразовый) | + ## 10. Правило процесса (выведено на этой задаче) > **Договорённость, не записанная в файл, для агента не существует.** diff --git a/personal/tech/id3-tag-repair.md b/personal/tech/id3-tag-repair.md index bbf40a47..f3a0273e 100755 --- a/personal/tech/id3-tag-repair.md +++ b/personal/tech/id3-tag-repair.md @@ -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 # без промпта +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а`,