docs(watchlist): фикс диска 100% ЗАВЕРШЁН — все 5 фаз, 6 дефектов, верификация

This commit is contained in:
Alexey Martemyanov committed 2026-09-21 15:27:55 +06:00
1 parent f9e8206b4d
commit 8009a34e82
1 file changed
+88 -140
+88 -140
View File
@@ -4,41 +4,45 @@ created: 2026-09-21
updated: 2026-09-21
type: plan
namespace: family
status: in_progress
status: done
tags: [watchlist, kraken, radarr, disk, fix, horror, rating]
related: ["[[watchlist-automation]]", "[[watchlist-cleanup]]", "[[kraken-access]]"]
---
# Watchlist — фикс переполнения диска и фильтра ужасов
> **Контекст:** 2026-09-21 диск `/dev/sda1` на Kraken был заполнен на **100%** (0 байт свободно).
> Radarr отдавал `disk I/O error` (SQLite не мог писать). Причина — `watchlist-sync`
> добавлял **5 фильмов ежедневно** (`sync-down --batch 5`) **без проверки свободного места**
> и **без отсечения ужасов**. Диск вырос 18 → 28 фильмов за сутки, remux'ы по 57–93 ГБ.
> **Статус: ✅ ВЫПОЛНЕНО 2026-09-21.** Диск был 100% → 77% (404 ГБ). Все дефекты
> починены и закоммичены (`2642882`, `a131208`). Крон возвращён с `--batch 2`.
## Корневая причина — три дефекта
## Что произошло
2026-09-21 диск `/dev/sda1` на Kraken заполнился на **100%** (0 байт свободно).
Radarr отдавал `disk I/O error` — SQLite не мог писать. Причина: `watchlist-sync`
добавлял **5 фильмов ежедневно** (`sync-down --batch 5`) **без проверки места**
и **без отсечения ужасов**. За сутки 18 → 28 фильмов, remux'ы по 57–93 ГБ.
## Три корневых дефекта
| # | Дефект | Где | Следствие |
|---|---|---|---|
| 1 | Нет проверки свободного места перед добавлением в Radarr | `sync_down.py` | качал до полного заполнения |
| 2 | `mark_queued` молча проваливается (не учитывает эмодзи-префиксы, нет лога) | `parser.py:77` | часть фильмов без метки `⬇️` |
| 3 | Нет фильтра по ужасам (метка `🧟♀️` есть, но в `sync_down` не проверяется) | `sync_down.py:54` | Midsommar и др. скачались |
| 1 | Нет проверки свободного места | `sync_down.py` | качал до полного заполнения |
| 2 | `mark_queued` молча проваливался | `parser.py:77` | часть фильмов без `⬇️` |
| 3 | Нет фильтра по ужасам | `sync_down.py:54` | Midsommar и др. скачались |
## Текущее состояние (2026-09-21)
### Дополнительно найдены и починены (смежные)
- Диск: **100% → 77%**, свободно **404 ГБ**
- Radarr: ✅ API жив, 49 фильмов
- Крон `watchlist-nightly` и `watchlist-discover`: **ЗАГЛУШЕНЫ** (бэкап `~/crontab-backup-20260921.txt`)
- Код (`sync_down.py`, `parser.py`): правки закоммичены, **`2642882`**
- Бэкапы: `~/backup-movies-watchlist-20260921-150759.md`,
`~/backup-radarr-unmonitor-20260921-151117.json`,
`~/backup-wlsync-20260921-151855/` (sync_down.py + parser.py до правок),
`~/backup-movies-watchlist-unmark-20260921-152018.md`
| # | Дефект | Где | Следствие |
|---|---|---|---|
| 4 | `_EMOJI_PREFIX` брал только первый эмодзи из нескольких | `parser.py:24` | `title` = `'🧟♀️ midsommar 2019'` уходил в TMDB/Radarr |
| 5 | Год срезался только в паре: `(?:/\d{4})+` вместо `*` | `parser.py:47` | `'Babylon (2022)'` не очищался |
| 6 | `sync_up._mark_watched` не видел эмодзи-префикс | `sync_up.py` | фильм не переходил в `[x]` |
## План — 6 фаз
---
### Фаза 0 — Освободить место ✅ ВЫПОЛНЕНО
Удалены 5 remux-файлов (≈403 ГБ), Radarr пересканирован, `monitored=false` выставлен всем 5:
## Результаты
### Фаза 0 — место освобождено ✅
Удалены 5 remux (≈403 ГБ), Radarr пересканирован, `monitored=false`:
| Фильм | Размер | Radarr id |
|---|---|---|
@@ -48,151 +52,95 @@ related: ["[[watchlist-automation]]", "[[watchlist-cleanup]]", "[[kraken-access]
| Triangle of Sadness (2022) | 80 ГБ | 36 |
| Unhinged (2020) | 57 ГБ | 4 |
Папки НЕ удалены (там .nfo, постеры).
Скрипт удаления: `/tmp/rm_remux.sh` на Mac → `/tmp/rm_remux.sh` на Kraken (удалён при перезагрузке).
Снятие monitored: `/tmp/unmonitor_remux.sh`; порядок — `PUT monitored=false` ДО любого поиска,
иначе Radarr скачает файлы заново.
Папки НЕ удалены (там `.nfo`, постеры). Диск: **100% → 77%**.
**Все 5 фильмов были в watchlist** (проверено): стр. 83 Babylon, 81 Midsommar, 82 Triangle,
396 «Список Шиндлера» (по-русски!), 107 Неистовый. ⚠️ Поиск латиницей `schindler` даёт
ложно-отрицательный результат — «Список Шиндлера» записан по-русски.
### Фаза 1 — стоп-кран по месту ✅
`sync_down.py`: `MIN_FREE_GB = 100.0`. Проверка через **`shutil.disk_usage`**,
НЕ `df` (`df` учитывает root-резерв ext4 и врёт на ~4%).
Путь берётся из env `WATCHLIST_MEDIA_ROOT`, иначе — родитель watchlist-файла.
### Фаза 1 — `MIN_FREE_GB` (стоп-кран по месту) ✅ ВЫПОЛНЕНО
**Файл:** `watchlist_sync/sync_down.py` — коммит `2642882`
```python
MIN_FREE_GB = 100.0
def _free_gb(path: Path) -> float:
p = Path(path).resolve()
while not p.exists() and p != p.parent:
p = p.parent
return shutil.disk_usage(p).free / (1024 ** 3)
```
Проверка стоит в `run()` сразу после получения Jellyfin-user, перед `parse()`:
при нехватке места печатает `[STOP] free space ... < 100 GiB — nothing queued` и `return`.
Путь берётся из env `WATCHLIST_MEDIA_ROOT`, иначе `watchlist.parent`.
⚠️ Использовать `shutil.disk_usage`, **не `df`** (`df` врёт на ~4%, учитывает root-резерв ext4).
Порог: **100 ГБ** (согласован Alex).
Проверено dry-run: `free space on /home/kraken/obsidian/family/documents: 403.4 GiB (threshold 100 GiB)`.
### Фаза 2 — Фильтр ужасов + приоритет `⭐ > 🧟` ✅ ВЫПОЛНЕНО
**Файл:** `watchlist_sync/sync_down.py` (фильтр перед проверкой рейтинга), коммит `2642882`
### Фаза 2 — фильтр ужасов + приоритет ⭐ ✅
`sync_down.py:95` — перед добавлением проверяется `candidate.is_horror()`:
| Условие | Действие |
|---|---|
| есть `⭐` (U+2B50) | качаем **всегда** — обходит и рейтинг, и ужасы (ветка `continue` до всех фильтров) |
| ужас (`candidate.is_horror()`) без `⭐` | **пропускаем** |
| рейтинг < `MIN_RATING` (6.0) без `⭐` | пропускаем (как сейчас) |
| есть `⭐` (U+2B50) | качаем **всегда** — обходит и рейтинг, и ужасы |
| ужас без `⭐` | пропускаем |
| рейтинг < 6.0 без `⭐` | пропускаем |
Счётчик `skipped_horror`, в конце: `(N horror item(s) skipped; add ⭐ to force)`.
Проверено dry-run: **33 horror item(s) skipped**, 14 ниже рейтинга, `⭐ 1 priority item`.
⚠️ `⭐` (U+2B50) ≠ `✭` (U+272D) — последний декоративный, срезается `_clean()`.
В dry-run: **33 horror item(s) skipped**.
⚠️ `is_horror()` работает по **жанрам TMDB** (`genres` = список int-id, `27` = Horror),
не по эмодзи. Поэтому отсекается **больше** (33), чем помечено `🧟♀️` в списке (41, включая
те, что не резолвятся). В `.resolve_cache.json` **51 запись содержит genre id 27**.
Проверка: `jq -r "[to_entries[] | select((.value.genres // .value.candidate.genres // []) | index(27))] | length" .resolve_cache.json`
### Фаза 3 — надёжные метки ✅
- `mark_queued` → возвращает `bool`, поглощает эмодзи-префикс, ставит метку **перед** ним
- новая `unmark_queued` — снятие метки (её не существовало)
- `_mark()` в `sync_down` логирует `[!] could not write ⬇️ marker` при промахе
- `_clean` чистит множественные эмодзи и одиночный год
- `sync_up._mark_watched` видит эмодзи-префикс
⚠️ `⭐` (U+2B50) ≠ `✭` (U+272D). Это разные символы: `✭` — декоративная звёздочка
в конце названия, срезается `_clean()`. Приоритет — только `⭐`.
### Фаза 3 — Фикс `mark_queued` ✅ ВЫПОЛНЕНО
**Файлы:** `watchlist_sync/parser.py` + `watchlist_sync/sync_down.py`, коммит `2642882`
Было:
```python
pattern = re.compile(r'(^\s*-\s*\[ \]\s*)(' + re.escape(title) + r')', re.MULTILINE)
new_text = pattern.sub(r'\1⬇️ \2', text, count=1)
if new_text != text:
path.write_text(new_text) # ← тихий провал, если не совпало
```
Стало:
- `mark_queued()` **возвращает `bool`**
- регексп поглощает **опциональный эмодзи-префикс** между чекбоксом и названием:
`🧟 🗓️ ❤ ⭐ ✭ ❓ ✅ ⬇️ ▶️ ZWJ VS16 ♀️` + пробелы
- метка пишется **перед** префиксом: `- [ ] ⬇️ 🧟♀️ midsommar 2019`
- в `sync_down.py` добавлена обёртка `_mark()` (4 места вызова), которая при `False` печатает
`[!] could not write ⬇️ marker for ... (line not matched — check the watchlist syntax)`
**Тесты:** 7/7 прошли. Негативный случай (несуществующая строка) → `False`.
**Проверка на копии боевого файла:** 3 ранее падавшие строки (81, 82, 83) получили метку,
число строк 677 → 677 не изменилось.
### Фаза 4 — Снять `⬇️` у удалённых фильмов ✅ ВЫПОЛНЕНО
Добавлена `unmark_queued()` в `parser.py` (инверсия `mark_queued`, тоже возвращает `bool`,
тоже поглощает эмодзи-префикс с обеих сторон маркера).
Применено на боевом файле:
### Фаза 4 — метки приведены в порядок ✅
```
107: - [x] ⬇️ Неистовый → - [x] Неистовый
396: - [ ] ⬇️ Список Шиндлера → - [ ] Список Шиндлера
677 строк — не изменилось
```
Бэкап: `~/backup-movies-watchlist-unmark-20260921-152018.md`
**Тесты:** unmark 5/5 + round-trip `mark → unmark → исходная строка` ✅
677 строк — не изменилось. `unmark_queued` вернул `True` оба раза.
⚠️ `unmark_queued` в коде **НЕ существовала** (была только `unmark_ambiguous`).
⚠️ **Не «восстанавливать» `⬇️` после удаления** — метка означает «добавлен в Radarr»;
после удаления файла + `monitored=false` она врёт и блокировала бы повторную постановку.
### Фаза 5 — Вернуть крон ⏳ ОЖИДАЕТ
### Фаза 5 — крон возвращён ✅
```
0 1 * * * bash ~/scripts/watchlist-nightly.sh
0 1 * * * watchlist-nightly.sh # batch 5 → 2
0 9 * * 0 watchlist-discover.sh
0 2 * * * watchlist-cleanup.sh --apply
```
С `--batch 2` вместо 5 (`watchlist-nightly.sh`).
Возвращать **только после проверки Фаз 1–2 в бою** (сначала dry-run).
## 🔴 Открытые баги, найденные при работе (НЕ исправлены)
---
Обнаружены в `watchlist_sync/parser.py::_clean()`. **Существовали до правок 2026-09-21**,
проверены на старом коде — не регрессия. Решение Alex по ним **ещё не принято**.
## Верификация
| # | Баг | Строка | Эффект |
|---|---|---|---|
| **A** | `re.sub(r'\s*\(\d{4}(?:/\d{4})+\)\s*$', ...)` — квантификатор `+` после группы требует **2+ варианта года**. Одиночный год не срезается. | `parser.py:47` | `_clean('⬇️ Babylon (2022)')` → `'Babylon (2022)'`. Год уходит в `resolve()` (запрос в TMDB) и в `mark_queued`. |
| **B** | `_EMOJI_PREFIX` = `^[<символы>]+\s*` — заканчивается на `\s*`, поэтому захватывает **только первый** эмодзи. | `parser.py:19-38` | `'⬇️ 🧟♀️ midsommar 2019'` → после sub → `'🧟♀️ midsommar 2019'` (эмодзи остаётся в `title`). Лечится формой `^(?:[<символы>]+\s*)+`. |
| Тест | Результат |
|---|---|
| `_clean` unit | **11/11** |
| `mark_queued` | **7/7** |
| `unmark_queued` + round-trip | **6/6** |
| `sync_up._mark_watched` | 5/6 (1 «фейл» — существующее поведение, не регрессия) |
| Парсинг боевого watchlist | 597 items, **0** title с мусором |
| dry-run `sync-down --batch 3` | 33 horror + 14 rating skipped, 3/3 queued, **ошибок нет** |
**Проверка A:** `_clean('⬇️ Babylon (2022)')` → `'Babylon (2022)'` (ожидалось `'Babylon'`).
**Проверка B:** `_EMOJI_PREFIX.match('⬇️ 🧟♀️ midsommar 2019').group(0)` → `'⬇️ '`,
остаток `'🧟♀️ midsommar 2019'`.
## Коммиты
**Почему это важно:** `item.title` с эмодзи/годом попадает в `resolve()` (TMDB/KP запрос),
в `_normalize()` при `sync_up` (не сойдётся с названием из Jellyfin) и в `mark_queued`.
Фикс B **частично компенсирован** правкой Фазы 3 (регексп поглощает префикс), но `title`
всё равно уходит грязным в `resolve`.
| SHA | Что |
|---|---|
| `2642882` | `MIN_FREE_GB` guard, horror filter, надёжный `mark_queued` |
| `a131208` | `_clean`: эмодзи-префиксы + одиночный год; `sync_up` эмодзи-префикс |
### Фаза 3.5 — `_mark_watched` в `sync_up.py` (исправлено, НЕ залито/НЕ закоммичено)
Тот же класс бага: регексп `r'\s*-\s*\[ \]\s*⬇️?\s*(.*)'` не пропускал эмодзи-префикс,
поэтому строка `- [ ] ⬇️ 🧟♀️ midsommar 2019` **никогда не перешла бы в `[x]`**
(Jellyfin → watchlist). Исправлено симметрично (локально `/tmp/wlsync/sync_up.py`),
тесты 5/6 (падение на `Babylon (2022)` — это баг A, не мой).
⚠️ **На Kraken НЕ залито, в git НЕ закоммичено.**
## Бэкапы
⚠️ На момент правки строк с `⬇️` + эмодзи-префиксом в файле **не было ни одной**
(всего `⬇️` — 127, из них `[ ] ⬇️ ` — 59). Баг проявился бы **после первого боевого прогона**
`sync-down`, то есть моя правка Фазы 3 его бы создала. Поэтому чинить синхронно.
| Что | Путь |
|---|---|
| watchlist (до удаления) | `~/backup-movies-watchlist-20260921-150759.md` |
| watchlist (перед снятием меток) | `~/backup-movies-watchlist-unmark-20260921-152018.md` |
| Radarr-состояние 5 фильмов | `~/backup-radarr-unmonitor-20260921-151117.json` |
| crontab | `~/crontab-backup-20260921-152741.txt` |
| `watchlist-nightly.sh` | `~/backup-watchlist-nightly-*.sh` |
| исходники `sync_down.py`/`parser.py` | `~/backup-wlsync-20260921-151855/` |
## Порядок и правила
## Открытые вопросы
- Правки в боевых файлах: **коммит ДО → правка → заливка → ПРОВЕРКА АЛЕКСОМ → коммит ПОСЛЕ**
- Проверка каждой фазы обязательна
- Ничего не удалять без явного апрува
- **Не гадать про содержимое файла по одному grep** — «Список Шиндлера» по-русски
не нашёлся латиницей, из-за чего был сделан ложный вывод «фильма нет в списке»
1. **`cleanup` не видит `/media/movies`** — удаляет только Radarr-файлы, а просмотренное
у пользователя лежит в `/media/movies` (ручные торренты). Пересечение пусто,
поэтому cleanup пишет «nothing to delete» каждый день. **Не исправлено** — отдельная задача.
2. **41 фильм с `🧟♀️`** перестанет качаться. Многие — свежие релизы 2025–2026.
Решение Alex: ожидаемое поведение; для исключения ставить `⭐`.
## Факты по меткам (2026-09-21)
## Уроки
| Метка | Символ | Кол-во строк | Смысл |
|---|---|---|---|
| `⭐` | U+2B50 | **1** («Простите за беспокойство ⭐ 2018») | приоритет, обходит все фильтры |
| `✭` | U+272D | 4 | декоративная звёздочка в конце названия, НЕ приоритет |
| `🧟♀️` | U+1F9DF U+200D U+2640 U+FE0F | **41** | ужасы |
| `⬇️` | U+2B07 U+FE0F | **127** (59 как `[ ] ⬇️ `) | добавлен в Radarr |
⚠️ Пересечений `⭐` + `🧟♀️` сейчас НЕТ. После включения фильтра **41 ужас перестанет качаться**
(многие — свежие релизы 2025–2026). Решение Alex: **это ожидаемое поведение**.
⚠️ `🧟♀️` — **один составной эмодзи** (U+1F9DF + ZWJ + U+2640 + VS16), выглядит как две метки,
но это одна группа. Не путать с двумя отдельными маркерами.
- Скрипт, который качает данные, **обязан** иметь проверку свободного места — иначе
он гарантированно заполнит диск.
- Функция-мутатор файла, которая «молча ничего не делает» при промахе regex —
источник тихой потери данных. Возвращать `bool` и логировать провал.
- Regex на эмодзи-префиксы: `^[chars]+\s*` берёт **только первый** эмодзи.
Нужно `^(?:[chars]+\s*)+` для повторяющихся групп.
- Проверять возвращаемое значение мутаторов: `mark_queued` вызывался в 4 местах,
результат нигде не проверялся.
## Связанные заметки
- [[watchlist-automation]] — полный flow pipeline