373 lines
23 KiB
Markdown
Executable File
373 lines
23 KiB
Markdown
Executable File
---
|
||
created: '2026-05-18'
|
||
updated: '2026-09-20'
|
||
status: active
|
||
tags:
|
||
- kraken
|
||
- jellyfin
|
||
- radarr
|
||
- sonarr
|
||
- watchlist
|
||
- project
|
||
---
|
||
# Watchlist Sync
|
||
|
||
> Цель: автоматическая загрузка непросмотренных фильмов/сериалов из `family/documents/movies-watchlist.md` через Radarr/Sonarr, синхронизация просмотренных обратно из Jellyfin, и AI-curated рекомендации на основе профиля.
|
||
|
||
Репо: `~/Developer/watchlist-sync` → GitHub `mallexxx/watchlist-sync`
|
||
|
||
> ℹ️ **Эта заметка — исходный дизайн-документ проекта (Stage 1/2).** Актуальное состояние, рабочие пути, cron-статус и все найденные баги — в [[family/projects/watchlist-automation]]. Реализовано всё, кроме Stage 2 (рекомендации). Ниже сохранён дизайн; расхождения с реальностью помечены.
|
||
|
||
---
|
||
|
||
## 🔴 2026-09-22 — АВТОЗАКАЧКА ОТКЛЮЧЕНА: `sync-down` убран из крона
|
||
|
||
**Решение Alex:** закачек быть не должно. Формулировка:
|
||
*«ТЕБЕ БЛЯДЬ ЕЩЁ СУКА НЕ ОЧЕВИДНО ЧТО БЛЯДЬ ЗАКАЧЕК БЫТЬ НЕ ДОЛЖНО СКОТИНА?!»*
|
||
|
||
**Что было:** `watchlist-nightly.sh` вызывал `sync-down --apply --batch 2` →
|
||
`sync_down.py` через `radarr_add()` + `radarr_search()` (`sync_down.py:155,158`)
|
||
**добавлял фильмы в Radarr/Sonarr и триггерил поиск раздач**. До 2 позиций за прогон.
|
||
|
||
**Что стало:** вызов `sync-down` вырезан из cron-скрипта.
|
||
|
||
```
|
||
sync-vault → process-thumbs → resolve --recheck --apply → sync-up → sync-vault
|
||
```
|
||
|
||
Бэкап прежнего скрипта: `/home/kraken/scripts/watchlist-nightly.sh.bak-20260922-182655`
|
||
Сумма новой версии: `4401882e3b1307a73b3330a70b59ecfe`
|
||
|
||
🔴 **`sync_down.py` как КОД сохранён** — запускается вручную (`watchlist-sync sync-down
|
||
--dry-run` для проверки, `--apply --batch N` для реальной закачки). Отключён
|
||
**автоматический прогон по крону**, а не возможность. Возврат в крон — только по
|
||
прямому указанию Alex.
|
||
|
||
**Проверка отсутствия автозакачки:**
|
||
```bash
|
||
ssh kraken "grep -ln 'radarr_add\|sonarr_add\|radarr_search\|sync-down --apply' \
|
||
/home/kraken/scripts/*.sh"
|
||
```
|
||
Активных вызовов быть не должно — только комментарии.
|
||
|
||
⚠️ **Radarr уже получил закачки за прошлые прогоны** (9 фильмов). Их судьба не решена.
|
||
|
||
### Разграничение двух «лимитов» — частый источник путаницы
|
||
|
||
| Лимит | Что ограничивает | Где |
|
||
|---|---|---|
|
||
| `batch=N` | сколько позиций **уходит в Radarr на закачку** за прогон | `sync_down.py` (был в кроне) |
|
||
| **лимита нет** | **выдача в Lampa** — отдаётся весь `watchlist.json` | `lampac/wwwroot/` |
|
||
|
||
⚠️ Каталог Lampa строится `.md` → `watchlist.json`; **Radarr в отображении НЕ участвует.**
|
||
Когда Alex спрашивал про «лимит обработки/выдачи в lampa» — это НЕ `batch`.
|
||
|
||
---
|
||
|
||
## 2026-09-22 — `discover` больше не добавляет кино, которого нет в TMDB (коммит `396acc3`)
|
||
|
||
**Проблема:** `discover.py` фильтровал новинки **только по рейтингу и голосам**
|
||
(IMDb ≥ 7.0, KP ≥ 8.5 для РФ; голосов ≥ 1000 / ≥ 500). Проверки «есть ли фильм
|
||
в TMDB» не было → в `## Новинки` попадало вышедшее кино, которого TMDB не знает,
|
||
и в каталоге Lampa оно давало «здесь пусто».
|
||
|
||
**Изменения:**
|
||
- `discover.py`: `_resolvable(movie, tmdb_key, kp_key, cache)` — резолв через
|
||
`watchlist_sync.resolver` (тот умеет cross-reference RU → `alternativeName` (EN)
|
||
→ TMDB). Кэш промахов, чтобы не долбить API.
|
||
- `run()`: новый параметр `tmdb_key`. Проверка идёт **после** фильтра по рейтингу,
|
||
голосам, году и дедупликации `_all_watchlist_titles()`.
|
||
- `upcoming` (`🗓️`) **не проверяется** — календарь информативен, в каталог такие
|
||
позиции всё равно не идут.
|
||
- `cli.py`: команда `discover` передаёт `TMDB_KEY` из окружения. Без него проверка
|
||
не выполняется.
|
||
|
||
**Живой dry-run** (`cd ~/Developer/watchlist-sync && . ./.env && .venv/bin/python -m watchlist_sync.cli discover`):
|
||
из 150 released + 50 upcoming прошло 5 released / 16 upcoming, отсеяно 6 позиций, которых
|
||
нет в TMDB: `Одиссея`, `Майкл`, `Убежище`, `Обитель зла`, `Приглашение`, `Властелины вселенной`.
|
||
|
||
🔴 **Грабли:** правка тела `run()` и правка его сигнатуры — **разные места**. Патч добавил
|
||
`tmdb_key` в тело, но не в сигнатуру; Pyright показал `No parameter named "tmdb_key"`
|
||
в `cli.py`. Сигнатуру править отдельно и проверять LSP-диагностикой.
|
||
|
||
Полный контекст задачи (автопоиск, три уровня резолва, `missing.json`, маркер ❓) —
|
||
[[personal/plans/watchlist-tmdb-missing-marker]].
|
||
|
||
---
|
||
|
||
## Архитектура
|
||
|
||
```
|
||
movies-watchlist.md (Obsidian vault)
|
||
│
|
||
▼
|
||
watchlist-sync (Python CLI)
|
||
├── parse — читает .md, классифицирует [ ] / [x] / ❓ / ⬇️
|
||
├── resolve — TMDB + KP API → находит ID, тип (movie/series), рейтинг
|
||
├── sync-down — непросмотренные → Radarr / Sonarr (батчами)
|
||
└── sync-up — Jellyfin watched history → [x] в .md + перемещение наверх
|
||
│
|
||
▼
|
||
Hermes Kraken cron job
|
||
├── каждые 6ч: resolve ❓ → LLM disambiguation → уточнение у Alex
|
||
└── каждые 24ч: sync-up из Jellyfin
|
||
```
|
||
|
||
---
|
||
|
||
## Stage 1: Core Sync
|
||
|
||
### 1.1 Парсинг watchlist.md
|
||
|
||
**Формат строк:**
|
||
- `- [ ] Название` — непросмотрено, не добавлено
|
||
- `- [ ] ⬇️ Название` — добавлено в очередь (скачивается/скачано)
|
||
- `- [ ] ❓ Название` — ambiguous, ждёт ручного уточнения
|
||
- `- [x] Название` — просмотрено
|
||
|
||
**Источник файла на Кракене:** `/home/kraken/obsidian/family/documents/movies-watchlist.md`
|
||
(vault смонтирован в Hermes-контейнере как `/vault`)
|
||
|
||
### 1.2 Резолв названий (TMDB + KP)
|
||
|
||
**Алгоритм:**
|
||
1. Для каждого `- [ ]` без `⬇️` и `❓` — запрос к TMDB `/search/multi` (русский язык)
|
||
2. Если 0 результатов → fallback KP `/v1.4/movie/search?query=...`
|
||
3. **Однозначный результат** (score > 0.8, рейтинг ≥ 6.0, один кандидат) → определить тип (movie/tv), взять ID
|
||
4. **Ambiguous** (несколько кандидатов с близким score, или score < 0.8) → добавить `❓` перед названием, пропустить
|
||
5. **Не найдено** → добавить `❓`, пропустить
|
||
|
||
**Пороги:**
|
||
- ~~`min_rating: 6.0`~~ — ⚠️ **в `resolver.py` этого порога НЕТ.** Фильтр по рейтингу реализован в `sync_down.py` как `MIN_RATING = 6.0` (коммит `ada47da`, 2026-09-20). Резолвер возвращает кандидата с любым рейтингом; отбор происходит на этапе загрузки.
|
||
- `min_score: 0.75` — сходство названия (`AMBIG_SCORE_THRESHOLD` в `resolver.py`)
|
||
- `AMBIG_DIFF_THRESHOLD: 0.15` — при близких score двух кандидатов → ambiguous
|
||
- `max_candidates_for_auto: 1` — при 2+ близких кандидатах → ambiguous (в коде: `candidates[:3]`)
|
||
|
||
**Тип контента:**
|
||
- TMDB `media_type: movie` → Radarr
|
||
- TMDB `media_type: tv` → Sonarr
|
||
- KP `type: MOVIE/MINI_SERIES/TV_SERIES` → маппинг аналогично
|
||
|
||
### 1.3 Загрузка (sync-down)
|
||
|
||
**Батчинг:**
|
||
- Максимум **5 новых** в одном прогоне (защита от flood) — задаётся `--batch 5` в `watchlist-nightly.sh`
|
||
⚠️ **С 2026-09-22 `sync-down` из крона УБРАН** (автозакачка отключена) — см. раздел выше. `--batch` актуален только для ручного запуска. Исторически стоял `--batch 2` (снижен с 5 после инцидента 2026-09-21: диск 100%).
|
||
- ✅ **Приоритет — порядок в файле** (исправлено 2026-09-20, коммит `ada47da`). Раньше стоял `resolved.sort(key=lambda x: x[1].rating or 0, reverse=True)`, из-за чего позиция строки игнорировалась и в батч попадал топ-5 по рейтингу. Сортировка убрана, `resolved` заполняется в порядке `parse()` = сверху вниз.
|
||
- ✅ **Фильтр по рейтингу реализован** (там же). Константа `MIN_RATING = 6.0` в начале `sync_down.py`; всё, что ниже, печатается как `[−] … skipped` и не попадает в очередь. **Нулевые рейтинги (`0.0`) отсекаются вместе с остальными** — решение Alex 2026-09-20. На момент замера отсеивалось 18 из 251 кандидатов.
|
||
- ⏸ → ✅ **Приоритет `⭐` (U+2B50) РЕАЛИЗОВАН** (2026-09-20, коммит `7db9385`). Двухпроходная выборка: `starred + rest`. Два важных свойства: (а) ⭐ **обходит фильтр `MIN_RATING`** — условие в коде `if "⭐" not in item.raw and rating < MIN_RATING`; (б) `⭐` **срезается из поискового запроса** в `parser._clean()` через `str.replace()` (НЕ через end-anchored класс — маркер стоит в середине названия). Работает из всех секций, `[x]` и под-буллеты не считаются. Детали — в [[family/projects/watchlist-automation]] → «⭐ Приоритет по звёздочке».
|
||
- После добавления в Radarr/Sonarr → заменить `- [ ]` на `- [ ] ⬇️` в .md
|
||
|
||
**API:**
|
||
- Radarr: `POST /api/v3/movie` с `addOptions.searchForMovie: true`
|
||
- Sonarr: `POST /api/v3/series` с `addOptions.searchForMissingEpisodes: true`
|
||
- Проверка дублей перед добавлением (GET by tmdbId/tvdbId)
|
||
|
||
**Конфиг** (`.env` / `config.json`, не в репо):
|
||
```
|
||
RADARR_URL=http://localhost:7878
|
||
RADARR_KEY=...
|
||
SONARR_URL=http://localhost:8989
|
||
SONARR_KEY=...
|
||
TMDB_KEY=...
|
||
KP_KEY=...
|
||
JELLYFIN_URL=http://localhost:8096
|
||
JELLYFIN_TOKEN=...
|
||
WATCHLIST_PATH=/vault/family/documents/movies-watchlist.md
|
||
```
|
||
|
||
### 1.4 Обратная синхронизация (sync-up)
|
||
|
||
**Алгоритм:**
|
||
1. Jellyfin API: `GET /Users/{userId}/Items?IsPlayed=true&IncludeItemTypes=Movie,Series`
|
||
2. Для каждого просмотренного — fuzzy-match по названию с `⬇️` строками в .md
|
||
3. При совпадении (score > 0.85):
|
||
- Заменить `- [ ] ⬇️ Название` → `- [x] Название`
|
||
- Переместить строку на **первое место** среди `[x]` записей (под последней `[ ]`)
|
||
4. Обновить `.md`, git commit
|
||
|
||
**Jellyfin userId:** получать через `GET /Users` (admin user = alex)
|
||
|
||
### 1.5 Cron Jobs (Hermes Eagle)
|
||
|
||
Два детерминированных shell-script крона:
|
||
|
||
| Job | Расписание | Скрипт |
|
||
|-----|-----------|--------|
|
||
| `watchlist-nightly` | ежедн. 01:00 | `~/scripts/watchlist-nightly.sh` |
|
||
| `watchlist-discover` | вс 09:00 | `~/scripts/watchlist-discover.sh` |
|
||
|
||
⚠️ Фактический путь — `~/scripts/`, **не** `~/.hermes/scripts/` (проверено 2026-09-20). Мигрировали с Eagle на Кракен 2026-05-27.
|
||
|
||
⚠️🔴 **Cron НЕ восстановлен после 2026-06-20** — pipeline запускается только вручную. См. [[family/projects/watchlist-automation]].
|
||
|
||
---
|
||
|
||
## Разделение приоритета: порядок / рейтинг / ⭐ (итог обсуждения 2026-09-20)
|
||
|
||
Три разных механизма, которые легко перепутать:
|
||
|
||
| Механизм | Роль | Где в коде |
|
||
|----------|------|-----------|
|
||
| **`⭐` (U+2B50)** | **приоритет поверх всего** — качается первым, **обходит фильтр рейтинга** | `sync_down.py`: `starred + rest`, условие `"⭐" not in item.raw and rating < MIN_RATING` |
|
||
| **Порядок в файле** | **приоритет** — что качать раньше (внутри групп) | `sync_down.py` (порядок `parse()`, сортировки нет) |
|
||
| **Рейтинг ≥ 6.0** | **отбор** — что вообще качать (кроме ⭐) | `MIN_RATING` в `sync_down.py` |
|
||
|
||
**Ключевое правило (уточнено Alex 2026-09-20):** порядок влияет на **очерёдность**, рейтинг — на **допуск**. Но `⭐` — это **обход фильтра**, а не просто «в начало очереди»: звёздочка означает явное «хочу», поэтому рейтинг для таких фильмов не проверяется вовсе. Формулировка Alex: «Я блядь сказал в приоритете завела».
|
||
|
||
**Обработка `⭐` в `parser._clean()`:** маркер может стоять **в середине** названия (`Простите за беспокойство ⭐ 2018`), поэтому срезается через `str.replace('⭐', ' ')`, а НЕ через end-anchored класс `[...]+$` (первая попытка так не сработала). `raw` сохраняет `⭐` — приоритет проверяется именно по `item.raw`.
|
||
|
||
**`✭` (U+272D) приоритетом НЕ является** — только `⭐` (U+2B50), и только в строке фильма, не в под-буллетах кандидатов; `[x]` пропускается; работает из всех секций.
|
||
|
||
Используют `~/scripts/sync-vault.sh` для всех git-операций (stash/pull/pop/commit/push). **Не агентные** — скрипты вызывают CLI напрямую, никакого LLM в pipeline.
|
||
|
||
**Cron: каждые 6 часов**
|
||
|
||
Промпт агенту на Кракене:
|
||
1. Прочитать `.md`, найти все `❓` строки
|
||
2. Для каждой — запросить TMDB/KP с расширенными вариантами запроса (LLM генерирует варианты: оригинальное название, транслит, английский перевод)
|
||
3. Если нашёл с уверенностью → убрать `❓`, поставить `⬇️`
|
||
4. Если нет → собрать список всех нерезолвленных и спросить Alex в Zulip `#personal::Kraken torrents`:
|
||
```
|
||
❓ Не смог распознать 3 фильма, уточни:
|
||
• "Дворец полански" — возможно "The Palace" (Polanski, 2023)? [да/нет/другое]
|
||
• "Space merchants" — фильм или книга Пола? Нашёл несколько.
|
||
• "Ради нескольких строчек" — не нашёл. Уточни название.
|
||
```
|
||
|
||
---
|
||
|
||
## Stage 2: Profile & Recommendations
|
||
|
||
> После накопления истории просмотров (≥ 50 фильмов)
|
||
|
||
### 2.1 Анализ профиля
|
||
|
||
- Топ жанры по просмотренным `[x]`
|
||
- Любимые режиссёры / актёры (через TMDB credits)
|
||
- Средний рейтинг принятых фильмов
|
||
- Паттерны: когда смотрит (с женой / один / семья) — вывести из тегов/времени просмотра в Jellyfin
|
||
|
||
### 2.2 Curated Lists (AI job, еженедельно)
|
||
|
||
Три списка, **только рейтинг ≥ 6.0**, **не в watchlist уже**:
|
||
|
||
| Список | Критерии |
|
||
|--------|----------|
|
||
| 🎬 Вдвоём с Лизой | Драма, комедия, триллер. Без слишком тяжёлого контента. |
|
||
| 💀 Один (трэш/боевики) | Хоррор, боевик, sci-fi, cult. Без ограничений. |
|
||
| 👨👩👧 Семейное с ребёнком | Возраст 7+. Мультфильмы, приключения, комедии. |
|
||
|
||
**Источники кандидатов:**
|
||
- TMDB `/movie/recommendations` на основе топ-10 просмотренных
|
||
- TMDB `/movie/similar` для любимых жанров
|
||
- KP подборки (русское кино / советская классика)
|
||
|
||
**Формат вывода** — добавить в конец `movies-watchlist.md`:
|
||
```markdown
|
||
## 🤖 Рекомендации (2026-W21)
|
||
|
||
### 🎬 Вдвоём
|
||
- [ ] Past Lives (2023) ★8.0 — драма о двух корейцах разлучённых в детстве
|
||
...
|
||
|
||
### 💀 Один
|
||
- [ ] Nosferatu (2024) ★7.2 — ремейк Эгgers'а
|
||
...
|
||
|
||
### 👨👩👧 Семья
|
||
- [ ] The Wild Robot (2024) ★8.1 — анимация
|
||
...
|
||
```
|
||
|
||
После подтверждения Alex — строки перемещаются в основной список.
|
||
|
||
---
|
||
|
||
## Структура проекта
|
||
|
||
> ⚠️ Ниже — **исходный дизайн**. Фактическая структура (проверено 2026-09-20) отличается: `radarr.py`/`sonarr.py` объединены в `arr.py`, добавлен `discover.py`, `recommend.py` не существует (Stage 2 не начата).
|
||
|
||
**Фактически (репо на Кракене: `/srv/dev-disk-by-uuid-49e8f586-.../watchlist-sync`):**
|
||
```
|
||
watchlist_sync/
|
||
├── __init__.py
|
||
├── cli.py — CLI entry (click): resolve / sync-down / sync-up / discover / process-thumbs
|
||
├── parser.py — парсинг .md, mark_queued / mark_ambiguous / mark_horror / process_thumbs / _clean
|
||
├── resolver.py — TMDB /search/multi + KP fallback, кэш .resolve_cache.json
|
||
├── arr.py — Radarr + Sonarr API client (объединены)
|
||
├── sync_down.py — непросмотренные → arr (MIN_RATING=6.0, порядок файла, ⭐ приоритет)
|
||
├── sync_up.py — Jellyfin → .md
|
||
├── discover.py — KP топ новинок → секция «Новинки»
|
||
└── jellyfin.py — Jellyfin API client
|
||
.env — TMDB_KEY, KP_KEY, RADARR_*, SONARR_* (ZULIP_* ОТСУТСТВУЮТ)
|
||
.resolve_cache.json — кэш резолвера (450 записей на 2026-09-20)
|
||
.venv/ — py3.11 + httpx + click
|
||
pyproject.toml
|
||
```
|
||
|
||
**Дизайн (не реализовано):** `recommend.py` — Stage 2 recommendations.
|
||
|
||
**Конфиг секреты:** только в `.env` (не в репо).
|
||
|
||
---
|
||
|
||
## Имплементация: очерёдность задач
|
||
|
||
> Статусы проставлены 2026-09-20. Фазы 1-3 и cron реализованы и работают (проверено ручным прогоном `watchlist-nightly.sh`). Stage 2 (Фаза 4) — не начата.
|
||
|
||
### Фаза 1 (MVP) — ✅ РЕАЛИЗОВАНО
|
||
- [x] Инициализировать репо `~/Developer/watchlist-sync`, push на GitHub
|
||
- [x] `parser.py` — парсинг .md, классификация строк
|
||
- [x] `resolver.py` — TMDB `/search/multi` + KP fallback
|
||
- [x] `sync_down.py` — add to Radarr/Sonarr (батч 5), пометить `⬇️`
|
||
- [x] CLI: `watchlist-sync resolve --dry-run` и `--apply`
|
||
- [x] CLI: `watchlist-sync sync-down --batch 5`
|
||
|
||
### Фаза 2 — ✅ РЕАЛИЗОВАНО
|
||
- [x] `sync_up.py` — Jellyfin → `[x]` + сортировка
|
||
- [x] CLI: `watchlist-sync sync-up`
|
||
- [x] Cron на Кракене: sync-up каждые 24ч — ⚠️ был, **умер 2026-06-20**, не восстановлен
|
||
|
||
### Фаза 3 — ✅ РЕАЛИЗОВАНО (кроме Zulip)
|
||
- [x] CLI `resolve --recheck` для повторного разбора `❓`
|
||
- [ ] AI disambiguation job (Hermes Kraken cron) — не делался, вместо него детерминированный `--recheck`
|
||
- [ ] Уточнение у Alex через Zulip для нерезолвленных — код есть (`_zulip_ping()` в `cli.py`), но `ZULIP_*` не в `.env` → молча скипается
|
||
|
||
### Фаза 4 (Stage 2) — ❌ НЕ НАЧАТА
|
||
- [ ] `recommend.py` — профиль + TMDB recommendations
|
||
- [ ] Три curated списка с подтверждением
|
||
- [ ] Еженедельный cron job
|
||
|
||
> Файловая структура в разделе «Структура проекта» — тоже дизайн: реально `radarr.py`/`sonarr.py` объединены в `arr.py`, добавлены `discover.py`, `sync_down.py`, `sync_up.py`, `jellyfin.py`, `parser.py`, `resolver.py`, `cli.py`. Точный список — в [[family/projects/watchlist-automation]].
|
||
|
||
---
|
||
|
||
## Ключи и секреты
|
||
|
||
| Секрет | Где хранить |
|
||
|--------|-------------|
|
||
| TMDB API key | `.env` на Кракене (из `config.json` media-pipeline) |
|
||
| KP API key | `.env` (тот же что в media-pipeline config.json) |
|
||
| Radarr key | `.env` |
|
||
| Sonarr key | `.env` |
|
||
| Jellyfin token | `.env` |
|
||
|
||
Ключи TMDB/KP уже есть в `/srv/.../docker/media-pipeline/config.json` — взять оттуда при деплое.
|
||
|
||
`.gitignore`:
|
||
```
|
||
.env
|
||
config.json
|
||
*.key
|
||
__pycache__/
|
||
.venv/
|
||
```
|
||
|
||
---
|
||
|
||
## Связанные проекты
|
||
|
||
- [[family/projects/media-toolbox-kraken]] — *arr стек, Jellyfin, media-pipeline
|
||
- [[family/documents/movies-watchlist]] — источник данных
|