From 02d4ecc2aae6c8d6764a60a876c8822a104ce6ed Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 11 Jul 2026 11:50:08 +0300 Subject: [PATCH] =?UTF-8?q?display=5Fname:=20=D1=81=D0=BB=D0=BE=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D0=B5=20=D1=80=D0=B0=D0=B7=D1=80=D0=B5=D1=88?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=BB=D0=B5=D0=B9=20+?= =?UTF-8?q?=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B6=D0=B8=D1=81=D1=81=D1=91=D1=80=D0=B0=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=BA=D1=81=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Единый источник полей отображаемого имени и один рендер полного ярлыка на всех путях (старт и «Обновить имя»/авто-перелив). Раньше старт давал полный «Название (режиссёр, год). Сезон N» но выбрасывал структуру, а перелив по распознаванию — усечённый «Title (Year)». - Слоистое разрешение скаляров имени: override → recognition(+match) → новый базовый слой «контекст» (download.parsed_context, JSON naming.Fields). - naming: публичные Fields/Label/Derive, вынесен единый рендер; удалён FormatTitleYear. Сводка сезонов вынесена в recognize.SeasonSummary. - Режиссёр из метабазы (решение A2): TMDB/TVDB credits через опциональный metadata.DirectorProvider; авто-матч кладёт в plan.Director, ручной выбор кандидата тянет credits и пиннит ovrDirector. Метабаза бьёт контекст. - refreshDisplayNameLocked строит полный ярлык из эффективных полей; инфо-панель ревью показывает загруженного режиссёра. - Миграция 0011_parsed_context + ER-схема. Всё косметика: на пути/раскладку не влияет, приём/вывод имени не валятся (best-effort). Закрывает беклог-задачу «Кнопка „Обновить имя“: полный формат ярлыка». OpenSpec: archive/2026-07-11-field-resolution-display-name (ingest, recognition, metadata-match, review). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/backlog/README.md | 1 - .../knopka-obnovit-imya-polnyj-format.md | 37 ----- docs/specs/database.md | 4 +- internal/httpapi/download.go | 3 +- internal/httpapi/files.go | 67 -------- internal/httpapi/files_internal_test.go | 53 ------ internal/httpapi/review.go | 5 +- internal/metadata/metadata.go | 9 ++ internal/metadata/tmdb.go | 45 ++++++ internal/metadata/tmdb_test.go | 60 +++++++ internal/metadata/tvdb.go | 29 ++++ internal/naming/naming.go | 118 ++++++++------ internal/naming/naming_test.go | 34 ++-- internal/naming/prompt.go | 18 +-- internal/naming/render_test.go | 22 +-- internal/recognize/metadata.go | 35 ++++ internal/recognize/recognize.go | 25 ++- internal/recognize/season.go | 72 +++++++++ internal/recognize/season_test.go | 49 ++++++ internal/store/download.go | 34 +++- .../store/migrations/0011_parsed_context.sql | 10 ++ internal/worker/catched_test.go | 23 ++- internal/worker/displayname_test.go | 93 +++++++++++ internal/worker/review.go | 90 +++++++++-- internal/worker/review_test.go | 41 ++++- internal/worker/worker.go | 27 +++- internal/worker/worker_test.go | 9 ++ .../.openspec.yaml | 2 + .../design.md | 153 ++++++++++++++++++ .../proposal.md | 74 +++++++++ .../specs/ingest/spec.md | 121 ++++++++++++++ .../specs/metadata-match/spec.md | 59 +++++++ .../specs/recognition/spec.md | 44 +++++ .../specs/review/spec.md | 105 ++++++++++++ .../tasks.md | 71 ++++++++ openspec/specs/ingest/spec.md | 90 +++++++++-- openspec/specs/metadata-match/spec.md | 33 ++++ openspec/specs/recognition/spec.md | 17 ++ openspec/specs/review/spec.md | 63 +++++--- .../partials/review_source_block.html | 2 +- 40 files changed, 1536 insertions(+), 311 deletions(-) delete mode 100644 docs/backlog/knopka-obnovit-imya-polnyj-format.md delete mode 100644 internal/httpapi/files_internal_test.go create mode 100644 internal/recognize/season.go create mode 100644 internal/recognize/season_test.go create mode 100644 internal/store/migrations/0011_parsed_context.sql create mode 100644 internal/worker/displayname_test.go create mode 100644 openspec/changes/archive/2026-07-11-field-resolution-display-name/.openspec.yaml create mode 100644 openspec/changes/archive/2026-07-11-field-resolution-display-name/design.md create mode 100644 openspec/changes/archive/2026-07-11-field-resolution-display-name/proposal.md create mode 100644 openspec/changes/archive/2026-07-11-field-resolution-display-name/specs/ingest/spec.md create mode 100644 openspec/changes/archive/2026-07-11-field-resolution-display-name/specs/metadata-match/spec.md create mode 100644 openspec/changes/archive/2026-07-11-field-resolution-display-name/specs/recognition/spec.md create mode 100644 openspec/changes/archive/2026-07-11-field-resolution-display-name/specs/review/spec.md create mode 100644 openspec/changes/archive/2026-07-11-field-resolution-display-name/tasks.md diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 1cddb1d..40ae296 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -42,7 +42,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог - [Ревью уведомлений в Telegram (аудит текстов и формата)](telegram-revyu-uvedomleniy.md) — зонтичный проход по всем текстам бота: полнота карточек, единый язык, оформление; порождает под-задачи - [Download id в Telegram моноширинным (code) для tap-to-copy](telegram-download-id-code.md) — слать id как `code`; требует включить parse mode (HTML) в send() + escape всех текстов -- [Кнопка «Обновить имя»: полный формат ярлыка (Название (режиссёр, год), сезон)](knopka-obnovit-imya-polnyj-format.md) — Перелив имени даёт усечённое «Title (Year)» вместо полного формата add-шага (режиссёр+сезон); нужный `render` уже есть - [Мгновенные обновления через SSE](sse-obnovleniya.md) — Живые обновления прогресса сейчас на htmx-поллинге (фаза 2 веб-UI) — просто и работает… - [Шум ERROR фоновых циклов при недоступной зависимости](oshibki-klassifikaciya-i-konvencii-logirovaniya.md) — Остаток задачи логирования: ext.* ERROR-шторм при недоступном qBittorrent + эскалация устойчивого сбоя тика _(ревью Fable)_ - [Версии/качество одного тайтла (репаки, апгрейд 1080p → 2160p)](versii-kachestvo-repaki.md) — По калибровке болей (2026-07-02) — не боль, из приоритета выпало diff --git a/docs/backlog/knopka-obnovit-imya-polnyj-format.md b/docs/backlog/knopka-obnovit-imya-polnyj-format.md deleted file mode 100644 index f279bdd..0000000 --- a/docs/backlog/knopka-obnovit-imya-polnyj-format.md +++ /dev/null @@ -1,37 +0,0 @@ -# Кнопка «Обновить имя»: полный формат ярлыка (Название (режиссёр, год), сезон) - -**Приоритет:** низкий · **Теги:** web-ui, naming, review - -Кнопка «Обновить имя» (перелив распознанного названия в `display_name` и ярлык -раздачи) сейчас генерирует усечённый формат `Title (Year)` через -`naming.FormatTitleYear(plan.Title, plan.Year)` -(`internal/worker/review.go:1110`). Ожидается тот же формат, что и «полное» -отображаемое имя add-шага: **`Название (режиссёр, год)`**, а для сериала — плюс -**`. Сезон N`**. Всё опционально, кроме названия. - -Нужный формат уже реализован приватной `render()` в -`internal/naming/naming.go:104-140` (`movie: "Title (Director, Year)"`, `series` -+ `". Сезон N"`), но `RefreshDisplayName` его не использует — комментарий у -`FormatTitleYear` (`naming.go:88-95`) прямо отмечает: «у перелива режиссёра и -сезона нет». Задача — устранить это расхождение: перелив должен давать тот же -ярлык, что и add-шаг. - -Шаги: -- Экспонировать `render`-логику (публичная обёртка в `internal/naming`) или - собрать `extracted` из плана и позвать её из `RefreshDisplayName`. -- **Сезон** — из плана (`recognize.PlanFile.Season`, `recognize.go:77`): для - сериала взять сезон (единый — подставить, разные — решить: опустить или взять - минимальный; свериться с логикой сводки сезонов на странице). -- **Режиссёр** — открытый вопрос: в `recognize.Plan` его сейчас нет. Найти - источник (матч метабазы TMDB/TVDB несёт режиссёра? recognition?) или, если - недоступен, опустить (формат допускает отсутствие). Уточнить, откуда add-шаг - берёт режиссёра, и переиспользовать тот же источник. -- Все части, кроме названия, опциональны — пустой режиссёр/год/сезон просто - выпадают из скобок/хвоста (как уже делает `render`). - -Проверить: единый формат ярлыка раздачи и `display_name`; санитайзинг и лимит -длины (`maxNameLen`) сохраняются; поведение на `done`/`orphaned` (кнопка гейтится -наличием распознавания, не состоянием — `internal/httpapi/download.go:142`). - -Связано: `openspec/specs/recognition`, `openspec/specs/review`, пакеты -`internal/naming`, `internal/worker`, `internal/recognize`. diff --git a/docs/specs/database.md b/docs/specs/database.md index b562c64..3b3f8c4 100644 --- a/docs/specs/database.md +++ b/docs/specs/database.md @@ -13,7 +13,8 @@ > `0003_source_miss_count`, `0004_candidate_url`, `0005_display_name`, > `0006_ulid_identity` (Go-миграция: ULID-идентификаторы, `download_infohash`), > `0007_file_link_size`, `0008_rfc3339_time` (метки времени → RFC 3339 UTC, -> `DEFAULT` убран), `0009_download_torrent` (байты `.torrent`-файла). +> `DEFAULT` убран), `0009_download_torrent` (байты `.torrent`-файла), +> `0010_retried_at`, `0011_parsed_context` (структура имени из контекста, JSON). Назначение таблиц и почему так — [architecture.md](architecture.md) → «Хранилище». Значения `state` и переходы — [workflow.md](workflow.md). @@ -40,6 +41,7 @@ erDiagram TEXT source_ref "NOT NULL; magnet/url/путь" TEXT display_name "NOT NULL DEFAULT ''; имя раздачи (rename qBittorrent), заголовок в UI (миграция 0005)" TEXT context "NOT NULL DEFAULT ''" + TEXT parsed_context "NOT NULL DEFAULT ''; структура имени из контекста (naming, JSON), базовый слой display_name (миграция 0011)" TEXT state "NOT NULL; см. workflow.md; активность выводится только из state" TEXT error_code "nullable" TEXT error_msg "nullable" diff --git a/internal/httpapi/download.go b/internal/httpapi/download.go index 01f5c84..ba3c379 100644 --- a/internal/httpapi/download.go +++ b/internal/httpapi/download.go @@ -6,6 +6,7 @@ import ( "strconv" "time" + "git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/worker" ) @@ -141,7 +142,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet view.RecTitle = rd.Plan.Title view.OriginalTitle = rd.Plan.OriginalTitle if view.IsSeries { - view.Season = seasonSummary(rd.Plan) + view.Season = recognize.SeasonSummary(rd.Plan) } view.Year = rd.Plan.Year // Ручное обновление имени доступно, когда есть распознанное название, diff --git a/internal/httpapi/files.go b/internal/httpapi/files.go index 6dc41a0..5bf2503 100644 --- a/internal/httpapi/files.go +++ b/internal/httpapi/files.go @@ -1,10 +1,6 @@ package httpapi import ( - "sort" - "strconv" - "strings" - "git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/recognize" ) @@ -40,69 +36,6 @@ func buildFileRows(plan recognize.Plan, preview []layout.Link) []fileRow { return rows } -// seasonSummary собирает верхнеуровневую сводку сезонов сериальной раздачи по -// эпизодным файлам плана. Сезон задан на файле (мультисезонные паки), поэтому -// сводим множество различных сезонов; Season nil/0 — спецвыпуски. Примеры: -// «Сезон 2», «Сезоны 1–3», «Сезоны 1, 3–4», «Спецвыпуски», «Сезоны 1–2, спецвыпуски». -func seasonSummary(plan recognize.Plan) string { - seen := map[int]bool{} - specials := false - for _, f := range plan.Files { - if f.Role != recognize.RoleEpisode { - continue - } - n := 0 - if f.Season != nil { - n = *f.Season - } - if n <= 0 { - specials = true - continue - } - seen[n] = true - } - nums := make([]int, 0, len(seen)) - for n := range seen { - nums = append(nums, n) - } - sort.Ints(nums) - - var parts []string - switch { - case len(nums) == 1: - parts = append(parts, "Сезон "+strconv.Itoa(nums[0])) - case len(nums) > 1: - parts = append(parts, "Сезоны "+seasonRanges(nums)) - } - if specials { - if len(parts) == 0 { - parts = append(parts, "Спецвыпуски") - } else { - parts = append(parts, "спецвыпуски") - } - } - return strings.Join(parts, ", ") -} - -// seasonRanges схлопывает возрастающие номера сезонов в диапазоны: -// [1,2,3] → «1–3», [1,3,4] → «1, 3–4». -func seasonRanges(nums []int) string { - var out []string - for i := 0; i < len(nums); { - j := i - for j+1 < len(nums) && nums[j+1] == nums[j]+1 { - j++ - } - if j == i { - out = append(out, strconv.Itoa(nums[i])) - } else { - out = append(out, strconv.Itoa(nums[i])+"–"+strconv.Itoa(nums[j])) - } - i = j + 1 - } - return strings.Join(out, ", ") -} - // roleLabel — человекочитаемая роль файла раскладки. func roleLabel(role string) string { switch role { diff --git a/internal/httpapi/files_internal_test.go b/internal/httpapi/files_internal_test.go deleted file mode 100644 index 55e6b13..0000000 --- a/internal/httpapi/files_internal_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package httpapi - -import ( - "testing" - - "git.vakhrushev.me/av/jellybit/internal/recognize" -) - -func TestSeasonSummary(t *testing.T) { - // ep — эпизодный файл с заданным (или nil) сезоном. - ep := func(season *int) recognize.PlanFile { - return recognize.PlanFile{Role: recognize.RoleEpisode, Season: season} - } - n := func(v int) *int { return &v } - - cases := []struct { - name string - files []recognize.PlanFile - want string - }{ - {"пусто", nil, ""}, - {"один сезон", []recognize.PlanFile{ep(n(2)), ep(n(2))}, "Сезон 2"}, - {"диапазон", []recognize.PlanFile{ep(n(1)), ep(n(2)), ep(n(3))}, "Сезоны 1–3"}, - {"разрыв", []recognize.PlanFile{ep(n(1)), ep(n(3)), ep(n(4))}, "Сезоны 1, 3–4"}, - {"несортированный вход", []recognize.PlanFile{ep(n(3)), ep(n(1)), ep(n(2))}, "Сезоны 1–3"}, - {"только спецвыпуски (nil)", []recognize.PlanFile{ep(nil)}, "Спецвыпуски"}, - {"только спецвыпуски (0)", []recognize.PlanFile{ep(n(0))}, "Спецвыпуски"}, - {"сезоны и спецвыпуски", []recognize.PlanFile{ep(n(1)), ep(n(2)), ep(nil)}, "Сезоны 1–2, спецвыпуски"}, - {"один сезон и спецвыпуски", []recognize.PlanFile{ep(n(1)), ep(nil)}, "Сезон 1, спецвыпуски"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - got := seasonSummary(recognize.Plan{Files: c.files}) - if got != c.want { - t.Errorf("seasonSummary = %q, want %q", got, c.want) - } - }) - } -} - -// Не-эпизодные файлы (main/subtitle/…) не влияют на сводку сезонов. -func TestSeasonSummary_IgnoresNonEpisodes(t *testing.T) { - s := 2 - plan := recognize.Plan{Files: []recognize.PlanFile{ - {Role: recognize.RoleEpisode, Season: &s}, - {Role: recognize.RoleMain}, - {Role: recognize.RoleSubtitle}, - {Role: recognize.RoleIgnore}, - }} - if got := seasonSummary(plan); got != "Сезон 2" { - t.Errorf("seasonSummary = %q, want «Сезон 2»", got) - } -} diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index 31bf725..018a834 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -9,6 +9,7 @@ import ( "strings" "git.vakhrushev.me/av/jellybit/internal/ident" + "git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/worker" ) @@ -45,6 +46,7 @@ type reviewView struct { IsSeries bool Title string OriginalTitle string + Director string // режиссёр эффективного источника (пусто — неизвестен) Year int SeasonSummary string // сводка сезонов для сериала (пусто для фильма) Provider string @@ -117,9 +119,10 @@ func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView view.IsSeries = rd.Plan.Type == "series" view.Title = rd.Plan.Title view.OriginalTitle = rd.Plan.OriginalTitle + view.Director = rd.Plan.Director view.Year = rd.Plan.Year if view.IsSeries { - view.SeasonSummary = seasonSummary(rd.Plan) + view.SeasonSummary = recognize.SeasonSummary(rd.Plan) } view.Reasons = rec.ReasonList() switch rd.Provider { diff --git a/internal/metadata/metadata.go b/internal/metadata/metadata.go index 087f603..717711e 100644 --- a/internal/metadata/metadata.go +++ b/internal/metadata/metadata.go @@ -51,3 +51,12 @@ type Provider interface { // (ключ — номер сезона). Нужен для валидации полноты сезон-пака. SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) } + +// DirectorProvider — опциональная способность провайдера отдать режиссёра записи +// по её нативному id (TMDB/TVDB credits; TVMaze не поддерживает и интерфейс не +// реализует). Проверяется type assertion'ом. Режиссёр — недоверенное +// косметическое поле для вывода отображаемого имени; выборка best-effort, пустая +// строка — режиссёр неизвестен. +type DirectorProvider interface { + Director(ctx context.Context, mt MediaType, id string) (string, error) +} diff --git a/internal/metadata/tmdb.go b/internal/metadata/tmdb.go index 778d276..9d5e03a 100644 --- a/internal/metadata/tmdb.go +++ b/internal/metadata/tmdb.go @@ -141,6 +141,51 @@ func (t *TMDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, return out, nil } +type tmdbCreditsResp struct { + Crew []struct { + Job string `json:"job"` + Name string `json:"name"` + } `json:"crew"` +} + +type tmdbTVDetailsResp struct { + CreatedBy []struct { + Name string `json:"name"` + } `json:"created_by"` +} + +// Director возвращает режиссёра: для фильма — из crew (job "Director"), для +// сериала — создателей (created_by). Пусто — режиссёр не указан. Best-effort: +// вызывающий гасит ошибку. +func (t *TMDB) Director(ctx context.Context, mt MediaType, id string) (string, error) { + params := url.Values{"api_key": {t.apiKey}, "language": {t.language}} + if mt == Series { + var resp tmdbTVDetailsResp + if err := getJSON(ctx, t.hc, t.log, logging.ServiceTMDB, "tv", + t.baseURL+"/tv/"+url.PathEscape(id)+"?"+params.Encode(), nil, &resp); err != nil { + return "", fmt.Errorf("tmdb tv %s: %w", id, err) + } + names := make([]string, 0, len(resp.CreatedBy)) + for _, c := range resp.CreatedBy { + if strings.TrimSpace(c.Name) != "" { + names = append(names, c.Name) + } + } + return strings.Join(names, ", "), nil + } + var resp tmdbCreditsResp + if err := getJSON(ctx, t.hc, t.log, logging.ServiceTMDB, "movie/credits", + t.baseURL+"/movie/"+url.PathEscape(id)+"/credits?"+params.Encode(), nil, &resp); err != nil { + return "", fmt.Errorf("tmdb movie credits %s: %w", id, err) + } + for _, c := range resp.Crew { + if c.Job == "Director" && strings.TrimSpace(c.Name) != "" { + return c.Name, nil + } + } + return "", nil +} + // yearOf достаёт год из даты вида "1999-03-31". func yearOf(date string) int { if len(date) < 4 { diff --git a/internal/metadata/tmdb_test.go b/internal/metadata/tmdb_test.go index ba028ee..e4e143a 100644 --- a/internal/metadata/tmdb_test.go +++ b/internal/metadata/tmdb_test.go @@ -152,3 +152,63 @@ func TestNewTMDB_RequiresKey(t *testing.T) { t.Fatal("want error without api_key") } } + +func TestTMDB_DirectorMovie(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/movie/603/credits" { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"crew":[ + {"job":"Producer","name":"Joel Silver"}, + {"job":"Director","name":"Lana Wachowski"} + ]}`)) + })) + defer srv.Close() + + got, err := newTMDB(t, srv.URL).Director(context.Background(), Movie, "603") + if err != nil { + t.Fatalf("Director: %v", err) + } + if got != "Lana Wachowski" { + t.Errorf("director = %q, want «Lana Wachowski»", got) + } +} + +func TestTMDB_DirectorSeriesCreatedBy(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/tv/60622" { + t.Errorf("path = %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"created_by":[{"name":"Noah Hawley"}]}`)) + })) + defer srv.Close() + + got, err := newTMDB(t, srv.URL).Director(context.Background(), Series, "60622") + if err != nil { + t.Fatalf("Director: %v", err) + } + if got != "Noah Hawley" { + t.Errorf("director = %q, want «Noah Hawley»", got) + } +} + +// Нет режиссёра в crew → пустая строка (best-effort, не ошибка). +func TestTMDB_DirectorAbsent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"crew":[{"job":"Producer","name":"X"}]}`)) + })) + defer srv.Close() + + got, err := newTMDB(t, srv.URL).Director(context.Background(), Movie, "1") + if err != nil { + t.Fatalf("Director: %v", err) + } + if got != "" { + t.Errorf("director = %q, want пусто", got) + } +} + +// TMDB реализует опциональный DirectorProvider. +func TestTMDB_ImplementsDirectorProvider(t *testing.T) { + var _ DirectorProvider = (*TMDB)(nil) +} diff --git a/internal/metadata/tvdb.go b/internal/metadata/tvdb.go index 7535e7c..af6ca4b 100644 --- a/internal/metadata/tvdb.go +++ b/internal/metadata/tvdb.go @@ -190,6 +190,35 @@ type tvdbExtendedResp struct { } `json:"data"` } +type tvdbCharactersResp struct { + Data struct { + Characters []struct { + PeopleType string `json:"peopleType"` + PersonName string `json:"personName"` + } `json:"characters"` + } `json:"data"` +} + +// Director возвращает режиссёра из расширенных данных записи (characters с +// peopleType "Director"). Пусто — режиссёр не указан. Best-effort: вызывающий +// гасит ошибку. +func (t *TVDB) Director(ctx context.Context, mt MediaType, id string) (string, error) { + kind := "series" + if mt == Movie { + kind = "movies" + } + var resp tvdbCharactersResp + if err := t.get(ctx, kind+"/extended", "/"+kind+"/"+url.PathEscape(id)+"/extended", &resp); err != nil { + return "", fmt.Errorf("tvdb %s %s: %w", kind, id, err) + } + for _, c := range resp.Data.Characters { + if c.PeopleType == "Director" && strings.TrimSpace(c.PersonName) != "" { + return c.PersonName, nil + } + } + return "", nil +} + // SeasonEpisodeCounts считает число серий по сезонам из расширенных данных. func (t *TVDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) { var resp tvdbExtendedResp diff --git a/internal/naming/naming.go b/internal/naming/naming.go index 24e7091..7fde1c9 100644 --- a/internal/naming/naming.go +++ b/internal/naming/naming.go @@ -7,6 +7,11 @@ // режиссёра/сезона), при неудаче — алгоритмический фолбек без сети. Любой // сбой деградирует к пустой строке: приём загрузки никогда не падает из-за // вывода имени. +// +// Извлечённая структура ([Fields]) — базовый (наименее доверенный) слой +// источника полей имени: её сохраняют у загрузки (parsed_context) и позже +// переиспользуют при обновлении display_name (поле, которого нет в +// распознавании/матче, — например режиссёр из контекста — не теряется). package naming import ( @@ -22,24 +27,34 @@ import ( // maxNameLen — ограничение длины отображаемого имени (символов/рун). const maxNameLen = 200 -// mediaType — вид контента в извлечённой структуре. -type mediaType string - const ( - typeMovie mediaType = "movie" - typeSeries mediaType = "series" + typeMovie = "movie" + typeSeries = "series" ) -// extracted — структура имени, извлечённая из контекста (схема ответа LLM). -// Year и Director опциональны (пустое значение → в ярлык не попадают). -type extracted struct { - Type mediaType `json:"type"` - Title string `json:"title"` - OriginalTitle string `json:"original_title"` - Year int `json:"year"` - Director string `json:"director"` - Season *int `json:"season"` - IsRussian bool `json:"is_russian"` +// Fields — скалярные поля имени, извлечённые из одного источника (контекст). +// Это же схема ответа LLM и схема хранения parsed_context. Year/Director/Season +// опциональны (пустые в ярлык не попадают). +type Fields struct { + Type string `json:"type"` // "movie"|"series" + Title string `json:"title"` + OriginalTitle string `json:"original_title"` + Year int `json:"year"` + Director string `json:"director"` + Season *int `json:"season"` + IsRussian bool `json:"is_russian"` +} + +// SeasonLabel — сводка сезона из контекстного скаляра: «Сезон N» для сериала с +// заданным номером, иначе пусто. Для контекста сезон скалярный (в отличие от +// плана распознавания, где он per-file и сводится recognize.SeasonSummary). +// Используется как fallback-слой сводки сезонов, когда план распознавания её не +// даёт. +func (f Fields) SeasonLabel() string { + if f.Type == typeSeries && f.Season != nil && *f.Season > 0 { + return "Сезон " + strconv.Itoa(*f.Season) + } + return "" } // Namer выводит отображаемое имя. provider может быть nil — тогда работает @@ -65,72 +80,71 @@ func New(provider llm.Provider, attempts int, logger *slog.Logger) *Namer { return &Namer{provider: provider, attempts: attempts, log: logger} } -// DeriveName выводит отображаемое имя из контекста. hint — подсказка из -// magnet (dn), используется только фолбеком, если контекст пуст. Возвращает -// "" если имя получить не удалось (тогда вызывающий не задаёт rename). -func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string { +// Derive выводит отображаемое имя и извлечённую структуру. hint — подсказка из +// magnet (dn), используется только фолбеком, если контекст пуст. Возвращает имя +// ("" если вывести не удалось — вызывающий не задаёт rename) и извлечённую +// структуру с ok=true, если LLM дал валидные поля (тогда их JSON сохраняют как +// parsed_context). Фолбек полей не даёт (ok=false): его выход — только строка. +func (n *Namer) Derive(ctx context.Context, contextText, hint string) (name string, fields Fields, ok bool) { // Нет ни контекста, ни подсказки — выводить имя не из чего. LLM на пустом // входе способен лишь галлюцинировать (наблюдалось «Unknown»), поэтому его не // зовём: имя считается не выведенным, вызывающий добавит загрузку без rename. if strings.TrimSpace(contextText) == "" && strings.TrimSpace(hint) == "" { - return "" + return "", Fields{}, false } if n.provider != nil { - if ex, ok := n.extractViaLLM(ctx, contextText, hint); ok { - if name := render(ex); name != "" { - return name + if ex, extracted := n.extractViaLLM(ctx, contextText, hint); extracted { + if label := render(ex); label != "" { + return label, ex, true } } } - return fallbackName(contextText, hint) + return fallbackName(contextText, hint), Fields{}, false } -// FormatTitleYear собирает детерминированный ярлык «Title (Year)» из уже -// распознанного названия — без сети и LLM (перелив канонического имени после -// распознавания). Год опционален; имя очищается от управляющих символов и -// обрезается по длине. Пустой title → пустая строка. В отличие от render это -// НЕ формат add-шага (там ещё режиссёр и сезон): у перелива их нет. -func FormatTitleYear(title string, year int) string { +// DeriveName — тонкая обёртка над Derive, когда структура не нужна (возвращает +// только имя). +func (n *Namer) DeriveName(ctx context.Context, contextText, hint string) string { + name, _, _ := n.Derive(ctx, contextText, hint) + return name +} + +// Label собирает полный детерминированный ярлык display_name из эффективных +// полей: «Название (режиссёр, год)», для сериала — хвост «. <сводка сезонов>». +// Все части, кроме названия, опциональны и выпадают, если пусты. seasonSummary — +// уже готовая строка сводки (для плана — recognize.SeasonSummary; для контекста — +// «Сезон N»). Имя очищается от управляющих символов и обрезается по длине. +// Пустое название → пустая строка. Единый рендер для шага добавления и перелива +// имени по распознаванию, чтобы формат ярлыка совпадал на всех путях. +func Label(title, director string, year int, seasonSummary string) string { title = sanitize(title) if title == "" { return "" } - if year > 0 { - title += " (" + strconv.Itoa(year) + ")" - } - return truncate(title, maxNameLen) -} - -// render собирает из структуры короткий ярлык: -// - movie: "Title (Director, Year)" — режиссёр и год опциональны; -// - series: то же + ". Сезон N", если сезон задан. -// -// Имя очищается от управляющих символов/переводов строк и обрезается по -// длине. Пустой Title → пустая строка. -func render(ex extracted) string { - title := sanitize(ex.Title) - if title == "" { - return "" - } var paren []string - if d := sanitize(ex.Director); d != "" { + if d := sanitize(director); d != "" { paren = append(paren, d) } - if ex.Year > 0 { - paren = append(paren, strconv.Itoa(ex.Year)) + if year > 0 { + paren = append(paren, strconv.Itoa(year)) } name := title if len(paren) > 0 { name += " (" + strings.Join(paren, ", ") + ")" } - if ex.Type == typeSeries && ex.Season != nil && *ex.Season > 0 { - name += ". Сезон " + strconv.Itoa(*ex.Season) + if s := sanitize(seasonSummary); s != "" { + name += ". " + s } return truncate(name, maxNameLen) } +// render собирает ярлык из извлечённой структуры (контекст) через общий Label. +func render(f Fields) string { + return Label(f.Title, f.Director, f.Year, f.SeasonLabel()) +} + // sanitize убирает управляющие символы и переводы строк, схлопывает пробелы. func sanitize(s string) string { s = strings.Map(func(r rune) rune { diff --git a/internal/naming/naming_test.go b/internal/naming/naming_test.go index 8641fae..c8f577e 100644 --- a/internal/naming/naming_test.go +++ b/internal/naming/naming_test.go @@ -77,22 +77,32 @@ func TestDeriveNameHintPresentSkipsGuard(t *testing.T) { } } -func TestFormatTitleYear(t *testing.T) { +func TestLabel(t *testing.T) { cases := []struct { - title string - year int - want string + name string + title string + director string + year int + season string + want string }{ - {"Harold and the Purple Crayon", 2024, "Harold and the Purple Crayon (2024)"}, - {"Брат", 0, "Брат"}, - {" Fargo\n", 2015, "Fargo (2015)"}, - {"", 2024, ""}, - {" ", 0, ""}, + {"title only", "Брат", "", 0, "", "Брат"}, + {"title+year", "Fargo", "", 2015, "", "Fargo (2015)"}, + {"title+director+year", "Дюна", "Дени Вильнёв", 2024, "", "Дюна (Дени Вильнёв, 2024)"}, + {"director only", "Брат", "Алексей Балабанов", 0, "", "Брат (Алексей Балабанов)"}, + {"series one season", "Сёгун", "", 2024, "Сезон 2", "Сёгун (2024). Сезон 2"}, + {"series multi season", "Фарго", "", 0, "Сезоны 1–3", "Фарго. Сезоны 1–3"}, + {"series specials", "Рик и Морти", "", 0, "Спецвыпуски", "Рик и Морти. Спецвыпуски"}, + {"empty title", " ", "Кто-то", 2024, "Сезон 1", ""}, + {"sanitized", " Fargo\n", "", 2015, "", "Fargo (2015)"}, } for _, c := range cases { - if got := FormatTitleYear(c.title, c.year); got != c.want { - t.Errorf("FormatTitleYear(%q, %d) = %q, want %q", c.title, c.year, got, c.want) - } + t.Run(c.name, func(t *testing.T) { + if got := Label(c.title, c.director, c.year, c.season); got != c.want { + t.Errorf("Label(%q, %q, %d, %q) = %q, want %q", + c.title, c.director, c.year, c.season, got, c.want) + } + }) } } diff --git a/internal/naming/prompt.go b/internal/naming/prompt.go index 173d5de..4a0076e 100644 --- a/internal/naming/prompt.go +++ b/internal/naming/prompt.go @@ -40,10 +40,10 @@ const systemPrompt = `Ты извлекаешь из текста о торре // Транспортные ошибки провайдера (сеть/429/5xx) гасятся внутри llm.Provider; // здесь повторяем только переразбор невалидного ответа. ok=false, если за // все попытки валидного результата не получено. -func (n *Namer) extractViaLLM(ctx context.Context, contextText, hint string) (extracted, bool) { +func (n *Namer) extractViaLLM(ctx context.Context, contextText, hint string) (Fields, bool) { contextText = strings.TrimSpace(contextText) if contextText == "" { - return extracted{}, false + return Fields{}, false } user := "Контекст раздачи:\n" + contextText @@ -68,7 +68,7 @@ func (n *Namer) extractViaLLM(ctx context.Context, contextText, hint string) (ex // Транспортная ошибка/таймаут залогирована клиентом LLM (ext.*); // здесь — доменное решение «уходим в фолбек, приём не валим». log.Debug("naming llm failed, using fallback") - return extracted{}, false + return Fields{}, false } ex, perr := parseExtracted(resp.Content) @@ -81,22 +81,22 @@ func (n *Namer) extractViaLLM(ctx context.Context, contextText, hint string) (ex llm.Message{Role: llm.RoleUser, Content: "Ответ невалиден: " + perr.Error() + ". Верни строго один JSON-объект по схеме, без пояснений."}) } - return extracted{}, false + return Fields{}, false } // parseExtracted вытаскивает JSON-объект из ответа и валидирует минимум: // непустой title. Тип по умолчанию — movie. -func parseExtracted(raw string) (extracted, error) { +func parseExtracted(raw string) (Fields, error) { jsonStr, err := llm.ExtractJSONObject(raw) if err != nil { - return extracted{}, err + return Fields{}, err } - var ex extracted + var ex Fields if err := json.Unmarshal([]byte(jsonStr), &ex); err != nil { - return extracted{}, fmt.Errorf("naming: unmarshal: %w", err) + return Fields{}, fmt.Errorf("naming: unmarshal: %w", err) } if strings.TrimSpace(ex.Title) == "" { - return extracted{}, fmt.Errorf("naming: empty title") + return Fields{}, fmt.Errorf("naming: empty title") } if ex.Type != typeMovie && ex.Type != typeSeries { ex.Type = typeMovie diff --git a/internal/naming/render_test.go b/internal/naming/render_test.go index 47df10a..5e58922 100644 --- a/internal/naming/render_test.go +++ b/internal/naming/render_test.go @@ -10,52 +10,52 @@ func ptr(i int) *int { return &i } func TestRender(t *testing.T) { tests := []struct { name string - in extracted + in Fields want string }{ { name: "movie with director and year", - in: extracted{Type: typeMovie, Title: "Дюна: Часть вторая", Director: "Дени Вильнёв", Year: 2024}, + in: Fields{Type: typeMovie, Title: "Дюна: Часть вторая", Director: "Дени Вильнёв", Year: 2024}, want: "Дюна: Часть вторая (Дени Вильнёв, 2024)", }, { name: "movie with year only", - in: extracted{Type: typeMovie, Title: "Дюна: Часть вторая", Year: 2024}, + in: Fields{Type: typeMovie, Title: "Дюна: Часть вторая", Year: 2024}, want: "Дюна: Часть вторая (2024)", }, { name: "movie with director only", - in: extracted{Type: typeMovie, Title: "Брат", Director: "Алексей Балабанов"}, + in: Fields{Type: typeMovie, Title: "Брат", Director: "Алексей Балабанов"}, want: "Брат (Алексей Балабанов)", }, { name: "movie without director and year", - in: extracted{Type: typeMovie, Title: "Брат"}, + in: Fields{Type: typeMovie, Title: "Брат"}, want: "Брат", }, { name: "series with season", - in: extracted{Type: typeSeries, Title: "Сёгун", Year: 2024, Season: ptr(2)}, + in: Fields{Type: typeSeries, Title: "Сёгун", Year: 2024, Season: ptr(2)}, want: "Сёгун (2024). Сезон 2", }, { name: "series without season", - in: extracted{Type: typeSeries, Title: "Сёгун", Year: 2024}, + in: Fields{Type: typeSeries, Title: "Сёгун", Year: 2024}, want: "Сёгун (2024)", }, { name: "series season zero is omitted", - in: extracted{Type: typeSeries, Title: "Сёгун", Season: ptr(0)}, + in: Fields{Type: typeSeries, Title: "Сёгун", Season: ptr(0)}, want: "Сёгун", }, { name: "empty title yields empty name", - in: extracted{Type: typeMovie, Title: " ", Year: 2024}, + in: Fields{Type: typeMovie, Title: " ", Year: 2024}, want: "", }, { name: "control chars and newlines are sanitized", - in: extracted{Type: typeMovie, Title: "Дюна\n\tЧасть\x00 вторая", Year: 2024}, + in: Fields{Type: typeMovie, Title: "Дюна\n\tЧасть\x00 вторая", Year: 2024}, want: "Дюна Часть вторая (2024)", }, } @@ -70,7 +70,7 @@ func TestRender(t *testing.T) { func TestRenderTruncates(t *testing.T) { long := strings.Repeat("я", maxNameLen+50) - got := render(extracted{Type: typeMovie, Title: long}) + got := render(Fields{Type: typeMovie, Title: long}) if n := len([]rune(got)); n > maxNameLen { t.Errorf("длина имени = %d рун, want <= %d", n, maxNameLen) } diff --git a/internal/recognize/metadata.go b/internal/recognize/metadata.go index 7e53908..e668a39 100644 --- a/internal/recognize/metadata.go +++ b/internal/recognize/metadata.go @@ -2,6 +2,7 @@ package recognize import ( "context" + "log/slog" "strings" "unicode" @@ -143,10 +144,44 @@ func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c meta ProviderID: pid, Title: c.Title, Year: c.Year, + Director: directorOf(ctx, r.log, p, mt, c.ID), SeasonEpisodeCounts: counts, } } +// directorOf тянет режиссёра из провайдера по нативному id, если он умеет +// (DirectorProvider). Best-effort: ошибка/неподдержка → пусто, распознавание не +// валится. Режиссёр — недоверенное косметическое поле. +func directorOf(ctx context.Context, log *slog.Logger, p metadata.Provider, mt metadata.MediaType, id string) string { + dp, ok := p.(metadata.DirectorProvider) + if !ok { + return "" + } + d, err := dp.Director(ctx, mt, id) + if err != nil { + logctx.FromOr(ctx, log).Debug("metadata director skipped", "provider", p.Name(), "id", id) + return "" + } + return d +} + +// Director тянет режиссёра выбранного источника по (provider, id) — точка входа +// для ручного выбора кандидата в ревью (см. worker). Ищет провайдера поиска по +// имени; best-effort — нет провайдера/не умеет/ошибка → пусто. mt — тип из плана. +func (r *Recognizer) Director(ctx context.Context, mt MediaType, provider, providerID string) string { + metaType := metadata.Movie + if mt == MediaSeries { + metaType = metadata.Series + } + for _, p := range r.providers { + if p.Name() != provider { + continue + } + return directorOf(ctx, r.log, p, metaType, providerID) + } + return "" +} + // CandidateTag — провайдер и id для тега папки Jellyfin: внешний (из // TagProvider/TagID, напр. TVMaze → tvdb/imdb), если есть, иначе сам провайдер // поиска. Используется и в матче, и при сохранении кандидатов. diff --git a/internal/recognize/recognize.go b/internal/recognize/recognize.go index 1dfa29c..ebf04b3 100644 --- a/internal/recognize/recognize.go +++ b/internal/recognize/recognize.go @@ -80,14 +80,19 @@ type PlanFile struct { // Plan — структурированный результат распознавания (схема ответа LLM). type Plan struct { - Type MediaType `json:"type"` - Title string `json:"title"` - OriginalTitle string `json:"original_title,omitempty"` - Year int `json:"year,omitempty"` - ProviderHint string `json:"provider_hint,omitempty"` - Files []PlanFile `json:"files"` - Confidence float64 `json:"confidence"` - Notes string `json:"notes,omitempty"` + Type MediaType `json:"type"` + Title string `json:"title"` + OriginalTitle string `json:"original_title,omitempty"` + Year int `json:"year,omitempty"` + // Director — режиссёр (опц.). LLM его НЕ заполняет и не валидируется по нему; + // его вкладывает подтверждённый матч метабазы (buildMatch) или закреплённый в + // ревью источник (override). Недоверенное косметическое поле для вывода + // отображаемого имени; в plan-санитайзинг не входит (чистится на рендере). + Director string `json:"director,omitempty"` + ProviderHint string `json:"provider_hint,omitempty"` + Files []PlanFile `json:"files"` + Confidence float64 `json:"confidence"` + Notes string `json:"notes,omitempty"` } // PreParse — черновой разбор имени релиза (go-ptn). @@ -111,6 +116,7 @@ type Match struct { ProviderID string // официальный id Title string // каноническое название Year int // каноничный год + Director string // режиссёр (best-effort из credits; пусто — нет) SeasonEpisodeCounts map[int]int // число серий по сезонам (для сериала) } @@ -245,6 +251,9 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) { if match.Year != 0 { plan.Year = match.Year } + if match.Director != "" { + plan.Director = match.Director + } } dec := decide(plan, pre, match, len(r.providers) > 0, r.threshold) diff --git a/internal/recognize/season.go b/internal/recognize/season.go new file mode 100644 index 0000000..a29a5ff --- /dev/null +++ b/internal/recognize/season.go @@ -0,0 +1,72 @@ +package recognize + +import ( + "sort" + "strconv" + "strings" +) + +// SeasonSummary собирает верхнеуровневую сводку сезонов сериальной раздачи по +// эпизодным файлам плана. Сезон задан на файле (мультисезонные паки), поэтому +// сводим множество различных сезонов; Season nil/0 — спецвыпуски. Примеры: +// «Сезон 2», «Сезоны 1–3», «Сезоны 1, 3–4», «Спецвыпуски», «Сезоны 1–2, спецвыпуски». +// Пустая строка — эпизодных файлов нет (нечего сводить). Общий хелпер для веб-UI +// (сводка сезонов на карточке/ревью) и для ярлыка display_name (naming). +func SeasonSummary(plan Plan) string { + seen := map[int]bool{} + specials := false + for _, f := range plan.Files { + if f.Role != RoleEpisode { + continue + } + n := 0 + if f.Season != nil { + n = *f.Season + } + if n <= 0 { + specials = true + continue + } + seen[n] = true + } + nums := make([]int, 0, len(seen)) + for n := range seen { + nums = append(nums, n) + } + sort.Ints(nums) + + var parts []string + switch { + case len(nums) == 1: + parts = append(parts, "Сезон "+strconv.Itoa(nums[0])) + case len(nums) > 1: + parts = append(parts, "Сезоны "+seasonRanges(nums)) + } + if specials { + if len(parts) == 0 { + parts = append(parts, "Спецвыпуски") + } else { + parts = append(parts, "спецвыпуски") + } + } + return strings.Join(parts, ", ") +} + +// seasonRanges схлопывает возрастающие номера сезонов в диапазоны: +// [1,2,3] → «1–3», [1,3,4] → «1, 3–4». +func seasonRanges(nums []int) string { + var out []string + for i := 0; i < len(nums); { + j := i + for j+1 < len(nums) && nums[j+1] == nums[j]+1 { + j++ + } + if j == i { + out = append(out, strconv.Itoa(nums[i])) + } else { + out = append(out, strconv.Itoa(nums[i])+"–"+strconv.Itoa(nums[j])) + } + i = j + 1 + } + return strings.Join(out, ", ") +} diff --git a/internal/recognize/season_test.go b/internal/recognize/season_test.go new file mode 100644 index 0000000..2ed8260 --- /dev/null +++ b/internal/recognize/season_test.go @@ -0,0 +1,49 @@ +package recognize + +import "testing" + +func TestSeasonSummary(t *testing.T) { + // ep — эпизодный файл с заданным (или nil) сезоном. + ep := func(season *int) PlanFile { + return PlanFile{Role: RoleEpisode, Season: season} + } + n := func(v int) *int { return &v } + + cases := []struct { + name string + files []PlanFile + want string + }{ + {"пусто", nil, ""}, + {"один сезон", []PlanFile{ep(n(2)), ep(n(2))}, "Сезон 2"}, + {"диапазон", []PlanFile{ep(n(1)), ep(n(2)), ep(n(3))}, "Сезоны 1–3"}, + {"разрыв", []PlanFile{ep(n(1)), ep(n(3)), ep(n(4))}, "Сезоны 1, 3–4"}, + {"несортированный вход", []PlanFile{ep(n(3)), ep(n(1)), ep(n(2))}, "Сезоны 1–3"}, + {"только спецвыпуски (nil)", []PlanFile{ep(nil)}, "Спецвыпуски"}, + {"только спецвыпуски (0)", []PlanFile{ep(n(0))}, "Спецвыпуски"}, + {"сезоны и спецвыпуски", []PlanFile{ep(n(1)), ep(n(2)), ep(nil)}, "Сезоны 1–2, спецвыпуски"}, + {"один сезон и спецвыпуски", []PlanFile{ep(n(1)), ep(nil)}, "Сезон 1, спецвыпуски"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := SeasonSummary(Plan{Files: c.files}) + if got != c.want { + t.Errorf("SeasonSummary = %q, want %q", got, c.want) + } + }) + } +} + +// Не-эпизодные файлы (main/subtitle/…) не влияют на сводку сезонов. +func TestSeasonSummary_IgnoresNonEpisodes(t *testing.T) { + s := 2 + plan := Plan{Files: []PlanFile{ + {Role: RoleEpisode, Season: &s}, + {Role: RoleMain}, + {Role: RoleSubtitle}, + {Role: RoleIgnore}, + }} + if got := SeasonSummary(plan); got != "Сезон 2" { + t.Errorf("SeasonSummary = %q, want «Сезон 2»", got) + } +} diff --git a/internal/store/download.go b/internal/store/download.go index cf33134..fcab92d 100644 --- a/internal/store/download.go +++ b/internal/store/download.go @@ -173,14 +173,19 @@ type Infohash struct { // Download — строка таблицы download. type Download struct { - ID string `db:"id"` // ULID (lowercase), публичный ключ домена - SourceType SourceType `db:"source_type"` - SourceRef string `db:"source_ref"` - DisplayName string `db:"display_name"` // имя раздачи (rename в qBittorrent), заголовок в веб-UI - Context string `db:"context"` - State State `db:"state"` - ErrorCode sql.NullString `db:"error_code"` - ErrorMsg sql.NullString `db:"error_msg"` + ID string `db:"id"` // ULID (lowercase), публичный ключ домена + SourceType SourceType `db:"source_type"` + SourceRef string `db:"source_ref"` + DisplayName string `db:"display_name"` // имя раздачи (rename в qBittorrent), заголовок в веб-UI + Context string `db:"context"` + // ParsedContext — извлечённая на шаге добавления структура имени (naming, JSON: + // type/title/original_title/year/director/season). Базовый (наименее + // доверенный) слой источника полей display_name; пусто, пока не выведена. + // Косметика: на пути/раскладку не влияет. + ParsedContext string `db:"parsed_context"` + State State `db:"state"` + ErrorCode sql.NullString `db:"error_code"` + ErrorMsg sql.NullString `db:"error_msg"` // SourceMissCount — счётчик подряд идущих тиков сверки без раздачи в // qBittorrent (дебаунс пропажи источника, см. state-reconciliation). SourceMissCount int `db:"source_miss_count"` @@ -806,6 +811,19 @@ WHERE id = ?`, name, FormatTime(Now()), id); err != nil { return nil } +// SetParsedContext сохраняет извлечённую из контекста структуру имени (JSON) — +// базовый слой источника display_name (см. capability ingest). Косметика: FSM не +// двигает, на пути/раскладку не влияет. Пустой json допустим (нечего сохранять). +func (s *Store) SetParsedContext(ctx context.Context, id, jsonStr string) error { + if _, err := s.DB.ExecContext(ctx, ` +UPDATE download +SET parsed_context = ?, updated_at = ? +WHERE id = ?`, jsonStr, FormatTime(Now()), id); err != nil { + return fmt.Errorf("set parsed context %s: %w", id, err) + } + return nil +} + // setState выполняет UPDATE состояния. reviveOK=true — вызов из гарда // (ActivateIfNoOtherActive), которому переход терминал→активное разрешён; // иначе предикат в UPDATE не даёт молча оживить терминальную задачу. diff --git a/internal/store/migrations/0011_parsed_context.sql b/internal/store/migrations/0011_parsed_context.sql new file mode 100644 index 0000000..5458b7f --- /dev/null +++ b/internal/store/migrations/0011_parsed_context.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- Извлечённая на шаге добавления структура имени (naming.extracted, JSON: +-- type/title/original_title/year/director/season) — базовый (наименее доверенный) +-- слой источника полей отображаемого имени. Сохраняется, чтобы поля из контекста +-- (в частности режиссёр) не терялись и переиспользовались при обновлении +-- display_name без повторного вызова LLM. Косметика: на пути/раскладку не влияет. +ALTER TABLE download ADD COLUMN parsed_context TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE download DROP COLUMN parsed_context; diff --git a/internal/worker/catched_test.go b/internal/worker/catched_test.go index f73054c..7bf58e6 100644 --- a/internal/worker/catched_test.go +++ b/internal/worker/catched_test.go @@ -2,10 +2,12 @@ package worker import ( "context" + "encoding/json" "errors" "testing" "time" + "git.vakhrushev.me/av/jellybit/internal/naming" "git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/store" ) @@ -14,18 +16,19 @@ import ( // момент (медленного) вывода имени, симулируя параллельную отмену. type fakeNamer struct { name string + fields naming.Fields // извлечённая структура (ok=true, если Title непуст) gotContext string calls int onCall func() } -func (f *fakeNamer) DeriveName(_ context.Context, contextText, _ string) string { +func (f *fakeNamer) Derive(_ context.Context, contextText, _ string) (string, naming.Fields, bool) { f.calls++ f.gotContext = contextText if f.onCall != nil { f.onCall() } - return f.name + return f.name, f.fields, f.fields.Title != "" } func catchedStore(id, infohash, createdAt, ctxText string) *fakeStore { @@ -53,7 +56,10 @@ func TestProcessCatchedAddsToQbit(t *testing.T) { st := catchedStore("1", catchedIH, nowStr, "Дюна 2") qb := &fakeQbt{} w := newTestWorker(st, qb) - nm := &fakeNamer{name: "Дюна: Часть вторая (2024)"} + nm := &fakeNamer{ + name: "Дюна: Часть вторая (2024)", + fields: naming.Fields{Type: "movie", Title: "Дюна: Часть вторая", Director: "Дени Вильнёв", Year: 2024}, + } w.SetNamer(nm) w.processCatched(context.Background()) @@ -78,6 +84,17 @@ func TestProcessCatchedAddsToQbit(t *testing.T) { if d.DisplayName != "Дюна: Часть вторая (2024)" { t.Errorf("display_name = %q", d.DisplayName) } + // Извлечённая структура сохранена как parsed_context (базовый слой полей имени). + if d.ParsedContext == "" { + t.Error("parsed_context пуст, ожидалась сохранённая структура") + } + var pf naming.Fields + if err := json.Unmarshal([]byte(d.ParsedContext), &pf); err != nil { + t.Fatalf("parsed_context не JSON: %v", err) + } + if pf.Director != "Дени Вильнёв" { + t.Errorf("parsed_context.director = %q, want «Дени Вильнёв»", pf.Director) + } } // Торрент пойманной загрузки уже присутствует в qBittorrent (добавлен ранее diff --git a/internal/worker/displayname_test.go b/internal/worker/displayname_test.go new file mode 100644 index 0000000..5c39e0f --- /dev/null +++ b/internal/worker/displayname_test.go @@ -0,0 +1,93 @@ +package worker + +import ( + "encoding/json" + "testing" + + "git.vakhrushev.me/av/jellybit/internal/naming" + "git.vakhrushev.me/av/jellybit/internal/recognize" + "git.vakhrushev.me/av/jellybit/internal/store" +) + +func parsedContext(t *testing.T, f naming.Fields) string { + t.Helper() + b, err := json.Marshal(f) + if err != nil { + t.Fatalf("marshal fields: %v", err) + } + return string(b) +} + +func episode(season int) recognize.PlanFile { + s := season + return recognize.PlanFile{Role: recognize.RoleEpisode, Season: &s} +} + +// effectiveDisplayName: слоистое разрешение полей ярлыка (план → контекст) и +// сводка сезонов из плана/контекста. +func TestEffectiveDisplayName(t *testing.T) { + cases := []struct { + name string + parsed naming.Fields + plan recognize.Plan + want string + }{ + { + name: "матч даёт режиссёра и год", + plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Director: "Дени Вильнёв", Year: 2024}, + want: "Дюна (Дени Вильнёв, 2024)", + }, + { + name: "режиссёр из контекста переживает распознавание без матча", + parsed: naming.Fields{Type: "movie", Title: "Дюна", Director: "Дени Вильнёв", Year: 2021}, + plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Year: 2024}, // без director + want: "Дюна (Дени Вильнёв, 2024)", // год из плана, режиссёр из контекста + }, + { + name: "режиссёр матча бьёт контекстного", + parsed: naming.Fields{Type: "movie", Title: "Дюна", Director: "Кто-то из контекста"}, + plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Director: "Дени Вильнёв", Year: 2024}, + want: "Дюна (Дени Вильнёв, 2024)", + }, + { + name: "год из контекста, когда плана нет", + parsed: naming.Fields{Type: "movie", Title: "Брат", Year: 1997}, + plan: recognize.Plan{Type: recognize.MediaMovie, Title: "Брат"}, // year 0 + want: "Брат (1997)", + }, + { + name: "сериал: сводка сезонов из плана", + plan: recognize.Plan{Type: recognize.MediaSeries, Title: "Фарго", + Files: []recognize.PlanFile{episode(1), episode(2), episode(3)}}, + want: "Фарго. Сезоны 1–3", + }, + { + name: "сериал: сезон из контекста, когда в плане нет эпизодов", + parsed: naming.Fields{Type: "series", Title: "Сёгун", Season: ptrInt(2)}, + plan: recognize.Plan{Type: recognize.MediaSeries, Title: "Сёгун", Year: 2024}, + want: "Сёгун (2024). Сезон 2", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + d := store.Download{} + if c.parsed.Title != "" { + d.ParsedContext = parsedContext(t, c.parsed) + } + if got := effectiveDisplayName(d, c.plan); got != c.want { + t.Errorf("effectiveDisplayName = %q, want %q", got, c.want) + } + }) + } +} + +// Битый parsed_context не валит рендер (best-effort): используется только план. +func TestEffectiveDisplayNameBrokenParsedContext(t *testing.T) { + d := store.Download{ParsedContext: "{не json"} + plan := recognize.Plan{Type: recognize.MediaMovie, Title: "Дюна", Year: 2024} + if got := effectiveDisplayName(d, plan); got != "Дюна (2024)" { + t.Errorf("effectiveDisplayName = %q, want «Дюна (2024)»", got) + } +} + +func ptrInt(v int) *int { return &v } diff --git a/internal/worker/review.go b/internal/worker/review.go index c669e6d..a8a8188 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -29,6 +29,7 @@ const ( ovrProviderID = "provider_id" // id в выбранной базе ovrTitle = "title" // запиненное каноническое название ovrYear = "year" // запиненный год + ovrDirector = "director" // режиссёр выбранного источника (credits метабазы) ovrForceReview = "force_review" // ручная перепривязка: не авто-раскладывать ) @@ -785,7 +786,15 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store. if cand.Year.Valid { year = int(cand.Year.Int64) } - for field, value := range sourcePins(cand.Provider, cand.ProviderID, title, year) { + // Режиссёр выбранного источника из метабазы (credits) — best-effort: пустой + // (нет провайдера/не умеет/ошибка) очищает возможный унаследованный пин; тогда + // в ярлыке режиссёр берётся из распознавания/авто-матча (plan.Director), а если + // и там пусто — из сохранённого контекста (нижний слой). + director := "" + if w.recognizer != nil { + director = w.recognizer.Director(ctx, candMediaType(rec), cand.Provider, cand.ProviderID) + } + for field, value := range sourcePins(cand.Provider, cand.ProviderID, title, year, director) { if err := w.store.SetOverride(ctx, id, field, value); err != nil { return fmt.Errorf("choose candidate: %w", err) } @@ -825,9 +834,22 @@ func (w *Worker) SetProviderID(ctx context.Context, id string, provider, provide if err != nil { return err } + // Режиссёр вручную заданного источника из метабазы (credits) — best-effort + // косметика: сбой чтения рекогниции не валит смену источника (тип по умолчанию + // movie, как трактует candMediaType(nil)). + director := "" + if w.recognizer != nil { + rec, rerr := w.store.GetCurrentRecognition(ctx, id) + if rerr != nil { + logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())). + Warn("set provider: recognition lookup for director failed", "error", rerr) + rec = nil + } + director = w.recognizer.Director(ctx, candMediaType(rec), provider, providerID) + } // Полный набор пинов: id задан вручную, название/год берём из плана // (очищаем возможный унаследованный пин прежнего источника). - for field, value := range sourcePins(provider, providerID, "", 0) { + for field, value := range sourcePins(provider, providerID, "", 0, director) { if err := w.store.SetOverride(ctx, id, field, value); err != nil { return fmt.Errorf("set provider: %w", err) } @@ -848,7 +870,7 @@ func (w *Worker) ClearProvider(ctx context.Context, id string) (err error) { if err != nil { return err } - for field, value := range sourcePins("none", "", "", 0) { + for field, value := range sourcePins("none", "", "", 0, "") { if err := w.store.SetOverride(ctx, id, field, value); err != nil { return fmt.Errorf("clear provider: %w", err) } @@ -862,7 +884,7 @@ func (w *Worker) ClearProvider(ctx context.Context, id string) (err error) { // пустая строка трактуется как «нет override» → берётся значение плана. Так // выбор любого источника даёт детерминированный эффективный план, а превью // совпадает с применением. Используется и в коммите (SetOverride), и в превью. -func sourcePins(provider, providerID, title string, year int) map[string]string { +func sourcePins(provider, providerID, title string, year int, director string) map[string]string { yr := "" if year > 0 { yr = strconv.Itoa(year) @@ -872,9 +894,19 @@ func sourcePins(provider, providerID, title string, year int) map[string]string ovrProviderID: providerID, ovrTitle: title, ovrYear: yr, + ovrDirector: director, } } +// candMediaType — тип из текущей рекогниции (для выборки режиссёра нужным +// эндпоинтом провайдера). Неизвестный/пустой → movie (дефолт схемы). +func candMediaType(rec *store.Recognition) recognize.MediaType { + if rec != nil && rec.MediaType.String == string(recognize.MediaSeries) { + return recognize.MediaSeries + } + return recognize.MediaMovie +} + // --- Данные для экрана ревью --- // ReviewData — всё, что нужно транспорту для отрисовки ревью. @@ -1011,7 +1043,10 @@ func (w *Worker) buildSources(ctx context.Context, downloadID string, rawPlan re // overrides, считает эффективный план и предпросмотр путей — эфемерно, без // записи. Гарантия preview == apply: тот же набор пинов запишет выбор. func (w *Worker) sourceOption(ctx context.Context, downloadID string, kind SourceKind, rawPlan recognize.Plan, base map[string]string, candID, provider, providerID, url, title string, year int, active bool) SourceOption { - eff := applyOverrides(rawPlan, mergeSourceOverrides(base, sourcePins(provider, providerID, title, year))) + // Режиссёр в эфемерном превью не тянем (пусто): он не влияет на пути раскладки, + // а per-candidate выборка credits дорога. Режиссёр появляется в инфо-панели + // после закрепления выбора (ovrDirector персистится в chooseCandidateLocked). + eff := applyOverrides(rawPlan, mergeSourceOverrides(base, sourcePins(provider, providerID, title, year, ""))) opt := SourceOption{ Kind: kind, CandidateID: candID, @@ -1035,13 +1070,14 @@ func (w *Worker) sourceOption(ctx context.Context, downloadID string, kind Sourc return opt } -// mergeSourceOverrides накладывает пины источника (provider/id/title/year) на -// неисточниковые overrides (media_type, ignored_files, force_review, ...). +// mergeSourceOverrides накладывает пины источника (provider/id/title/year/ +// director) на неисточниковые overrides (media_type, ignored_files, force_review, +// ...). func mergeSourceOverrides(base, pins map[string]string) map[string]string { m := make(map[string]string, len(base)+len(pins)) for k, v := range base { switch k { - case ovrProvider, ovrProviderID, ovrTitle, ovrYear: + case ovrProvider, ovrProviderID, ovrTitle, ovrYear, ovrDirector: continue default: m[k] = v @@ -1072,6 +1108,37 @@ func (w *Worker) effectivePlan(ctx context.Context, id string) (plan recognize.P return applyOverrides(plan, overrides), prov, pid, nil } +// effectiveDisplayName собирает полный ярлык display_name из эффективных полей: +// плана (override → распознавание+матч, уже свёрнуто effectivePlan) с fallback на +// сохранённый контекст (parsed_context) для полей, которых план не дал (например +// режиссёр из контекста при распознавании без матча). Сводка сезонов — +// recognize.SeasonSummary(plan) с fallback на контекстный скаляр. Единый формат с +// шагом добавления (общий naming.Label). parsed_context недоверен: битый JSON → +// пустой слой (best-effort), очистка полей — на рендере. +func effectiveDisplayName(d store.Download, plan recognize.Plan) string { + var ctxf naming.Fields + if s := strings.TrimSpace(d.ParsedContext); s != "" { + _ = json.Unmarshal([]byte(s), &ctxf) + } + title := plan.Title + if title == "" { + title = ctxf.Title + } + director := plan.Director + if director == "" { + director = ctxf.Director + } + year := plan.Year + if year == 0 { + year = ctxf.Year + } + season := recognize.SeasonSummary(plan) + if season == "" && plan.Type == recognize.MediaSeries { + season = ctxf.SeasonLabel() + } + return naming.Label(title, director, year, season) +} + // RefreshDisplayName — внешняя точка входа обновления отображаемого имени // (ручная кнопка): берёт w.mu и делегирует refreshDisplayNameLocked. func (w *Worker) RefreshDisplayName(ctx context.Context, id string) (err error) { @@ -1107,9 +1174,9 @@ func (w *Worker) refreshDisplayNameLocked(ctx context.Context, id string) error if err != nil { return fmt.Errorf("refresh display name: %w", err) } - name := naming.FormatTitleYear(plan.Title, plan.Year) + name := effectiveDisplayName(*d, plan) if name == "" { - return nil // пустое распознанное название — no-op + return nil // пустое эффективное название — no-op } if err := w.store.SetDisplayName(cctx, id, name); err != nil { return fmt.Errorf("refresh display name: %w", err) @@ -1201,6 +1268,9 @@ func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize. plan.Year = year } } + if d := overrides[ovrDirector]; d != "" { + plan.Director = d + } ignored := parseIgnored(overrides[ovrIgnoredFiles]) if len(ignored) > 0 { for i := range plan.Files { diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index 9eaa329..0689bee 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -470,6 +470,13 @@ func (m *memStore) SetDisplayName(_ context.Context, id, name string) error { return nil } +func (m *memStore) SetParsedContext(_ context.Context, id, jsonStr string) error { + if d, ok := m.downloads[id]; ok { + d.ParsedContext = jsonStr + } + return nil +} + func (m *memStore) SetSourceMissCount(_ context.Context, id string, n int) error { if d, ok := m.downloads[id]; ok { d.SourceMissCount = n @@ -656,10 +663,11 @@ func jsonMarshal(v any) (string, error) { // fakeRecognizer возвращает заданный результат; onCall — побочный эффект для // симуляции гонок (напр. отмена во время вызова LLM). type fakeRecognizer struct { - result recognize.Result - err error - onCall func() - calls int + result recognize.Result + err error + onCall func() + calls int + director string // возвращается Director (режиссёр выбранного источника) } func (f *fakeRecognizer) Recognize(_ context.Context, _ recognize.Input) (recognize.Result, error) { @@ -670,6 +678,10 @@ func (f *fakeRecognizer) Recognize(_ context.Context, _ recognize.Input) (recogn return f.result, f.err } +func (f *fakeRecognizer) Director(_ context.Context, _ recognize.MediaType, _, _ string) string { + return f.director +} + func testWorkerWith(st Store, qb QBittorrent, rec Recognizer, lay Layouter) *Worker { w := New(st, qb, rec, lay, Config{Category: "jellybit"}, slog.New(slog.NewTextHandler(io.Discard, nil))) @@ -1677,6 +1689,27 @@ func TestChooseCandidate_RefreshesDisplayName(t *testing.T) { } } +// A2: выбор кандидата тянет режиссёра из метабазы (recognizer.Director), пиннит +// его как ovrDirector и переливает полный ярлык «Название (режиссёр, год)». +func TestChooseCandidate_DirectorFromMetabase(t *testing.T) { + w, st, _ := refreshSetup(t, store.MetadataCandidate{ + Provider: "tmdb", ProviderID: "693134", + Title: store.NullString("Дюна"), Year: sql.NullInt64{Int64: 2024, Valid: true}, + }) + w.recognizer = &fakeRecognizer{director: "Дени Вильнёв"} + + if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil { + t.Fatalf("ChooseCandidate: %v", err) + } + if ov := st.overrides["1"]; ov[ovrDirector] != "Дени Вильнёв" { + t.Errorf("ovrDirector = %q, want «Дени Вильнёв»", ov[ovrDirector]) + } + const want = "Дюна (Дени Вильнёв, 2024)" + if got := st.downloads["1"].DisplayName; got != want { + t.Errorf("display_name = %q, want %q", got, want) + } +} + func TestChooseCandidate_RefreshDisplayName_QbitErrorTolerated(t *testing.T) { w, st, qb := refreshSetup(t, store.MetadataCandidate{ Provider: "tvdb", ProviderID: "343558", diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 4f3d689..23a2be7 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -13,6 +13,7 @@ package worker import ( "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -25,6 +26,7 @@ import ( "git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/logctx" "git.vakhrushev.me/av/jellybit/internal/magnet" + "git.vakhrushev.me/av/jellybit/internal/naming" "git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/store" @@ -52,6 +54,7 @@ type Store interface { // SetDisplayName обновляет отображаемое имя постфактум (перелив канонического // имени после распознавания) — без гарда состояния, FSM не двигает. SetDisplayName(ctx context.Context, id, name string) error + SetParsedContext(ctx context.Context, id, jsonStr string) error SetSourceMissCount(ctx context.Context, id string, n int) error SetSourceAddedAt(ctx context.Context, id string, t time.Time) error // SetRetriedAt проставляет время ручного retry — сброс базиса отсчёта @@ -104,12 +107,18 @@ type QBittorrent interface { // Recognizer — распознаватель (recognize.Recognizer). type Recognizer interface { Recognize(ctx context.Context, in recognize.Input) (recognize.Result, error) + // Director тянет режиссёра выбранного источника по (provider, id) из + // метабазы (credits) — для ручного выбора кандидата в ревью. Best-effort: + // нет провайдера/не умеет/ошибка → пустая строка. + Director(ctx context.Context, mt recognize.MediaType, provider, providerID string) string } // Namer выводит человекочитаемое отображаемое имя из контекста (naming.Namer). -// Пустой результат → rename в qBittorrent не задаём. nil → имя не выводим. +// Derive возвращает имя (пустое → rename в qBittorrent не задаём) и извлечённую +// структуру (её JSON сохраняется как parsed_context — базовый слой полей имени). +// nil-namer → имя не выводим. type Namer interface { - DeriveName(ctx context.Context, contextText, hint string) string + Derive(ctx context.Context, contextText, hint string) (name string, fields naming.Fields, ok bool) } // Layouter — раскладчик хардлинками (layout.Layouter). @@ -452,7 +461,19 @@ func (w *Worker) processCatched(ctx context.Context) { } var rename string if w.namer != nil { - rename = w.namer.DeriveName(cctx, cur.Context, hint) + name, fields, extracted := w.namer.Derive(cctx, cur.Context, hint) + rename = name + // Сохраняем извлечённую структуру как базовый слой полей имени + // (parsed_context) — режиссёр/год из контекста не теряются и + // переиспользуются при обновлении display_name. Best-effort: сбой не + // валит добавление (косметика). Пустая структура → нечего сохранять. + if extracted { + if blob, mErr := json.Marshal(fields); mErr == nil { + if sErr := w.store.SetParsedContext(cctx, cur.ID, string(blob)); sErr != nil { + logctx.From(cctx).Warn("catched save parsed context failed", "error", sErr) + } + } + } } addReq.Rename = rename addErr := w.qbt.Add(cctx, addReq) diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index a75d066..2226801 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -200,6 +200,15 @@ func (f *fakeStore) SetDisplayName(_ context.Context, id, name string) error { return nil } +func (f *fakeStore) SetParsedContext(_ context.Context, id, jsonStr string) error { + d, ok := f.downloads[id] + if !ok { + return fmt.Errorf("download %s not found", id) + } + d.ParsedContext = jsonStr + return nil +} + func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error { d, ok := f.downloads[id] if !ok { diff --git a/openspec/changes/archive/2026-07-11-field-resolution-display-name/.openspec.yaml b/openspec/changes/archive/2026-07-11-field-resolution-display-name/.openspec.yaml new file mode 100644 index 0000000..68b7174 --- /dev/null +++ b/openspec/changes/archive/2026-07-11-field-resolution-display-name/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-11 diff --git a/openspec/changes/archive/2026-07-11-field-resolution-display-name/design.md b/openspec/changes/archive/2026-07-11-field-resolution-display-name/design.md new file mode 100644 index 0000000..87fee88 --- /dev/null +++ b/openspec/changes/archive/2026-07-11-field-resolution-display-name/design.md @@ -0,0 +1,153 @@ +## Context + +`download.display_name` — косметический ярлык (список qBittorrent + заголовок в +веб-UI), не влияющий на пути/раскладку. Сейчас его выводят два расходящихся пути: + +- **Старт** (`worker.go:455`, `naming.DeriveName`): LLM извлекает `extracted` + (type/title/original_title/year/**director**/season), приватная `render` + собирает полный ярлык «Название (режиссёр, год). Сезон N». Структура после + рендера выбрасывается. +- **Обновление по распознаванию** (`review.go:1089`, `refreshDisplayNameLocked`): + ручная кнопка «Обновить имя» и авто-перелив при матче зовут + `naming.FormatTitleYear(plan.Title, plan.Year)` → усечённый `Title (Year)`. + +В системе уже есть слоистое разрешение полей плана: `effectivePlan` читает +`recognition.Plan` (в него `Recognize` вкладывает каноничные title/year матча) и +накладывает `override` (ручные пины) через `applyOverrides`. Не хватает **нижнего +слоя «контекст»** и **поля режиссёра**. + +Constraints (инварианты): вывод имени НИКОГДА не валит приём/добавление +(деградация к пустому); выход LLM и метабаз недоверенный; санитайзинг + лимит +`maxNameLen`; секреты не в логах; время UTC; ULID-идентификаторы; при изменении +схемы — миграция goose + ER-схема `docs/specs/database.md`. + +## Goals / Non-Goals + +**Goals:** + +- Единый слоистый источник скалярных полей имени и **одна** функция рендера + полного ярлыка, используемая и на старте, и при обновлении. +- Режиссёр из контекста сохраняется (`parsed_context`) и не теряется; режиссёр из + метабазы (TMDB/TVDB credits) его перекрывает. +- Кнопка «Обновить имя»/авто-перелив дают полный формат (закрытие беклог-задачи + `knopka-obnovit-imya-polnyj-format`). + +**Non-Goals:** + +- Не вводим EAV-таблицу «поле+источник» и не переносим title/year из + Plan/override в новое хранилище (Plan структурен — `files[]`; дубль исказит + «где правда»). +- Не храним провенанс поля (источник выводится при разрешении, если понадобится + в UI). +- Не добавляем режиссёра в промпт распознавания (его уже извлекает контекстный + `naming`; в план он приходит из матча). +- Не трогаем логику раскладки/путей/безопасности. + +## Decisions + +### 1. Хранение контекста — JSON-колонка `download.parsed_context` + +Извлечённую на старте структуру (`naming.extracted`) сериализуем JSON-ом в новую +колонку `download.parsed_context TEXT NOT NULL DEFAULT ''`. Это единственный +недостающий источник; он 1:1 с загрузкой, ставится один раз, читается точечно. + +*Почему не таблица-спутник:* join ради 1:1 без выгоды. *Почему не колонки-на-поле:* +миграция на каждое под-поле; JSON эволюционирует свободно, как уже хранится Plan. +*Почему вообще persist, а не пере-извлечение из `context` при обновлении:* лишний +вызов LLM; пользователь явно просил «сохраняем». + +### 2. Слоистое разрешение — хелпер в коде, не хранимый провенанс + +Вводим структуру эффективных полей имени и хелпер, собирающий её из слоёв +override → recognition(+match) → parsed_context (первый непустой на поле). +Источник каждого поля выводится позицией слоя; хранить его не нужно. Хелпер живёт +рядом с `effectivePlan`/`refreshDisplayNameLocked` (worker), т.к. только он имеет +доступ ко всем трём слоям под `w.mu`. + +### 3. Режиссёр: два входа из метабазы + слой override (решение A2) + +`recognize.Plan` получает опциональное `Director string \`json:"director,omitempty"\``. +LLM его не заполняет и не валидирует. Режиссёр из метабазы приходит **двумя** +путями, оба best-effort (ошибка/пусто/провайдер-без-режиссёра — напр. TVMaze — не +валят матч): + +- **Авто-матч** (`Recognize`/`matchMetadata`): при подтверждённом единичном матче + вкладываем режиссёра в `plan.Director` — ровно как уже вкладываются title/year. +- **Ручной выбор кандидата в ревью** (основной путь): `chooseCandidateLocked`/ + `AddManualSource` при закреплении кандидата тянут режиссёра выбранного `provider:id` + из credits и пишут его как **director-override** (новое поле `ovr` в наборе пинов + источника рядом с provider/id/title/year). `applyOverrides` кладёт значение в + `plan.Director`. Так режиссёр выбранного кандидата переживает перезагрузку + страницы (override персистентен) без колонки на `metadata_candidate`. + +Выборку credits по `provider:id` даёт новый метод интерфейса метабазы, проброшенный +в worker через интерфейс `Recognizer` (worker уже зависит от него; прямой зависимости +worker→metadata не заводим). Credits тянем **только** для подтверждённого/выбранного +источника, а не для каждого кандидата поиска — экономим внешние вызовы. + +*Альтернатива A1 (отклонена пользователем):* режиссёр только из авто-матча — +на основном (ручном) пути подтверждения матча не проявлялся бы. *Альтернатива +(колонка `metadata_candidate.director` + фетч на поиске):* вторая миграция и фетч +для всех кандидатов — дороже, отклонена. + +### 3a. Режиссёр — недоверенное косметическое поле + +`director` (из контекста, из авто-матча или из override) — недоверенный вход. Он +НЕ входит в санитайзинг плана (`recognition` «Санитайзинг человекочитаемых полей» +чистит `title`/`original_title`/`provider_hint`) и НЕ участвует в структурной +валидации/гейте. Очистка (управляющие символы, пробелы, лимит) применяется к нему +на **рендере ярлыка** (`render`/`sanitize` уже это делают). На пути/раскладку +режиссёр не влияет. + +### 4. Единый рендер полного ярлыка + +Экспортируем из `internal/naming` функцию, строящую ярлык из эффективных полей +(та же логика, что приватная `render`): «Название (режиссёр, год)» + для сериала +хвост сезона. `FormatTitleYear` удаляем (или переводим на новый рендер). +`refreshDisplayNameLocked` вместо `FormatTitleYear(plan.Title, plan.Year)` зовёт +новый рендер по эффективным полям. Старт (`DeriveName`) использует тот же рендер. + +### 5. Сводка сезонов — общая с UI, отдельная форма слоя + +Сезон не разрешается как плоский скаляр: у слоя `recognition` он выражен +**per-file** (`plan.Files[].Season`) и сворачивается в строку через `seasonSummary` +(`httpapi/files.go:47`: один → «Сезон N», диапазон → «Сезоны 1–3», спецвыпуски), а +у слоя `parsed_context` это **скаляр** `Season *int` (даёт лишь «Сезон N»). Поэтому: + +- Публичный рендер ярлыка принимает **готовую строку сводки сезонов**, а не сырое + число; хвост ярлыка — «. <сводка>» (пусто → хвоста нет). +- `effectiveNameFields` вычисляет эту строку по источнику: если есть план + распознавания с episode-ролями — `seasonSummary(plan)`; иначе (плана нет — + например ярлык на старте — или у сериала нет episode-ролей) fallback на + контекстный скаляр `parsed_context.season` → «Сезон N». Для фильма сезона нет. +- Логику `seasonSummary` выносим из `httpapi` в переиспользуемое место (`recognize` + или `naming`); `httpapi` и рендер зовут один хелпер — карточка страницы и ярлык + дают одинаковую сводку. Тесты `seasonSummary` переезжают вместе с кодом. + +## Risks / Trade-offs + +- **Доп. вызов credits к TMDB/TVDB при каждом подтверждённом матче** → best-effort + с таймаутом клиента; провал не валит матч; кэш метабаз — отдельная задача + беклога (`kesh-metabaz`). +- **Рассинхрон формата ярлыка и сводки сезонов между стартом, обновлением и + карточкой** → устраняется единой функцией рендера и общим `seasonSummary` + (ревью проверит, что все три пути зовут одно). +- **Миграция добавляет колонку существующим строкам** → `DEFAULT ''`, старые + загрузки просто без `parsed_context` (нижний слой пуст) — деградация штатная, + имя выводится из распознавания как и раньше. +- **`parsed_context` — недоверенный вход** (LLM/фолбек) → к его полям применяется + тот же санитайзинг/лимит на рендере; на пути/раскладку не влияет. + +## Migration Plan + +1. Миграция goose: `ALTER TABLE download ADD COLUMN parsed_context TEXT NOT NULL + DEFAULT ''`; обновить ER-схему `docs/specs/database.md`. +2. Существующие строки — с пустым `parsed_context`; поведение имени для них не + меняется (нижний слой пуст). Откат — колонка неиспользуемая, безопасно + игнорируется; down-миграция дропает колонку. +3. Раскатка обычная (копия бинаря на umbar), без ручных шагов данных. + +## Open Questions + +- Нет (развилки хранения/источника/сезона согласованы с пользователем до + proposal). diff --git a/openspec/changes/archive/2026-07-11-field-resolution-display-name/proposal.md b/openspec/changes/archive/2026-07-11-field-resolution-display-name/proposal.md new file mode 100644 index 0000000..c7d5881 --- /dev/null +++ b/openspec/changes/archive/2026-07-11-field-resolution-display-name/proposal.md @@ -0,0 +1,74 @@ +## Why + +Отображаемое имя (`download.display_name`) сейчас выводится по двум расходящимся +правилам. На шаге добавления `naming` извлекает из контекста структуру +(тип/название/год/**режиссёр**/сезон) и рендерит **полный** ярлык +«Название (режиссёр, год). Сезон N», но структуру после рендера **выбрасывает**. +А обновление имени по распознаванию (ручная кнопка «Обновить имя» и авто-перелив +при матче) даёт **усечённый** `Title (Year)` — без режиссёра и сезона. В итоге +режиссёр, добытый из контекста, теряется, а перелив ухудшает уже показанное имя. + +Причина расхождения — у распознавания нет поля режиссёра, а извлечение из +контекста нигде не сохраняется. Решаем в общем виде: поля имени приходят из +разных источников в разное время (контекст — на старте; метабаза — при +распознавании; правки — в ревью), поэтому вводим **единый слоистый источник** и +**одну** функцию рендера ярлыка. + +## What Changes + +- **Слоистое разрешение скалярных полей имени** (тип, название, ориг. название, + год, режиссёр, сезон-для-ярлыка): эффективное значение поля — первый непустой + слой сверху вниз `override` (человек) → `recognition` (LLM + матч метабазы) → + **новый базовый слой «контекст»** (извлечение `naming` на старте). Слои + `override`→`recognition` уже существуют (`effectivePlan`/`applyOverrides`); + добавляем нижний слой и единый хелпер разрешения. +- **Извлечение из контекста становится persistent.** Структуру, которую `naming` + извлекает на шаге добавления, сохраняем у загрузки в новой JSON-колонке + (`download.parsed_context`), чтобы переиспользовать без повторного вызова LLM. +- **Режиссёр из метабазы.** Подтверждённый матч TMDB/TVDB несёт режиссёра + (credits), он вкладывается в план так же, как уже вкладываются каноничные + название/год, и в разрешении бьёт контекстного (более проверенный источник). +- **Единый рендер ярлыка display_name.** Одна функция строит полный ярлык + «Название (режиссёр, год)» (+ для сериала сводка сезонов «. Сезон N» / + «. Сезоны 1–3» / «спецвыпуски») из эффективных полей и зовётся и на старте, и + при обновлении по распознаванию. Усечённый формат `Title (Year)` при обновлении + убирается. Это закрывает беклог-задачу «Кнопка „Обновить имя“: полный формат». +- **Инфо-часть ревью** показывает загруженного режиссёра (ранее — пустое + зарезервированное место). + +Всё перечисленное — косметика отображения: не влияет на пути файлов, +распознавание или раскладку. Приём и вывод имени по-прежнему не валят загрузку. + +## Capabilities + +### New Capabilities + +(нет — вводится слой внутри существующих capability, новых доменов нет) + +### Modified Capabilities + +- `ingest`: извлечение из контекста сохраняется у загрузки (`parsed_context`); + обновление отображаемого имени переходит с `Title (Year)` на полный ярлык из + эффективных полей (слоистое разрешение + сводка сезонов). +- `recognition`: план несёт опциональное скалярное поле `director` (источник — + подтверждённый матч, не LLM); режиссёр — недоверенное косметическое поле. +- `metadata-match`: подтверждённый матч и кандидат несут режиссёра (TMDB/TVDB + credits), когда он доступен, для вывода имени. +- `review`: подтверждение матча обновляет имя полным ярлыком; инфо-часть + выбранного источника показывает загруженного режиссёра. + +## Impact + +- Код: `internal/naming` (публичный рендер из эффективных полей, экспорт + извлечённой структуры), `internal/recognize` (`Plan.Director`), + `internal/metadata` (`Candidate.Director` + выборка credits в клиентах + TMDB/TVDB), `internal/worker` (сохранение `parsed_context` на старте; хелпер + `effectiveFields`; полный формат в `refreshDisplayNameLocked`), + `internal/store` (миграция goose: колонка `download.parsed_context`; чтение/ + запись), `internal/httpapi` (инфо-часть режиссёра; переиспользование + `seasonSummary`). +- БД: новая колонка `download.parsed_context` (TEXT, JSON) — обновить ER-схему + `docs/specs/database.md`. +- Внешние вызовы: дополнительный запрос credits к TMDB/TVDB при подтверждённом + матче (best-effort, недоступность не валит распознавание). +- Беклог: закрывается `docs/backlog/knopka-obnovit-imya-polnyj-format.md`. diff --git a/openspec/changes/archive/2026-07-11-field-resolution-display-name/specs/ingest/spec.md b/openspec/changes/archive/2026-07-11-field-resolution-display-name/specs/ingest/spec.md new file mode 100644 index 0000000..a2123da --- /dev/null +++ b/openspec/changes/archive/2026-07-11-field-resolution-display-name/specs/ingest/spec.md @@ -0,0 +1,121 @@ +# ingest Specification + +## ADDED Requirements + +### Requirement: Сохранение извлечённой из контекста структуры имени + +Система SHALL сохранять структуру имени, извлечённую LLM со структурированным +выводом на шаге добавления (тип, название, оригинальное название, год, режиссёр, +сезон), у загрузки (`download.parsed_context`, JSON), чтобы её поля могли +переиспользоваться при последующем выводе имени без повторного вызова LLM. +Алгоритмический фолбек структуры не даёт (его выход — только строка имени), тогда +`parsed_context` остаётся пустым — это штатно (нижний слой отсутствует). Сохранённая структура SHALL +быть **базовым (наименее доверенным) слоем** источника полей имени: её значения +берутся, только если более доверенный слой (распознавание/матч, ручные правки) +соответствующего поля не дал. + +Сохранение SHALL быть best-effort и косметическим: неудача записи `parsed_context` +SHALL NOT проваливать добавление загрузки, а сама структура SHALL влиять только на +отображаемое имя и SHALL NOT влиять на пути файлов, распознавание или раскладку. +Пустая/невыведенная структура (нет контекста и подсказки) SHALL приводить к +пустому `parsed_context` (нечего сохранять). + +#### Scenario: Извлечённый режиссёр сохраняется у загрузки + +- **GIVEN** контекст загрузки, из которого LLM извлёк режиссёра и год +- **WHEN** система выводит имя на шаге добавления +- **THEN** извлечённая структура (в т.ч. режиссёр) сохраняется в + `download.parsed_context` +- **AND** отображаемое имя формируется как и прежде (полный ярлык) + +#### Scenario: Сбой сохранения структуры не валит добавление + +- **GIVEN** запись `parsed_context` завершается ошибкой +- **WHEN** идёт шаг добавления загрузки +- **THEN** загрузка всё равно добавляется в qBittorrent (с `rename`, если имя + выведено) +- **AND** ошибка логируется, приём/добавление не проваливается + +#### Scenario: Пустой вход — пустая структура + +- **GIVEN** пойманная загрузка без контекста и без подсказки из полей источника +- **WHEN** выполняется шаг добавления +- **THEN** структура не выводится, `download.parsed_context` пуст + +## MODIFIED Requirements + +### Requirement: Обновление отображаемого имени по распознаванию + +Система SHALL уметь обновлять отображаемое имя загрузки после того, как +распознавание дало каноническое название, — переливая уже вычисленное имя (без +нового вызова LLM) в `download.display_name` и в имя раздачи qBittorrent. + +Источником имени SHALL быть **эффективные поля имени**, разрешённые по слоям +сверху вниз (берётся первый непустой слой): (1) ручные правки `override`; +(2) распознавание с вложенным подтверждённым матчем (`recognition`, куда матч +метабазы уже вложил каноничные название/год/режиссёра); (3) сохранённая +структура из контекста (`download.parsed_context`). Так каждое поле берётся из +самого доверенного доступного источника, а данные из контекста (например +режиссёр) не теряются, если распознавание/матч их не дали. Название из слоя +распознавания SHALL совпадать с тем, что использует раскладка (эффективный +`title`), чтобы отображаемое имя не расходилось с целевыми путями. + +Формат SHALL быть тем же полным детерминированным ярлыком, что и на шаге +добавления: «Название (режиссёр, год)», где режиссёр и год опциональны, а для +сериала добавляется сводка сезонов («. Сезон N» для одного сезона; «. Сезоны …» +для многосезонного пака; отметка спецвыпусков) — согласованная со сводкой сезонов +на экране просмотра. Применяются та же очистка от управляющих символов и обрезка +по ограничению длины. Пустой источник (нет ни распознавания, ни сохранённой +структуры, дающих непустое название) SHALL приводить к отсутствию изменений +(no-op). + +Переименование раздачи в qBittorrent SHALL адресоваться по infohash своей +раздачи и SHALL быть best-effort: сбой (раздача удалена, qBittorrent недоступен) +SHALL NOT проваливать обновление — `download.display_name` обновляется в любом +случае, ошибка внешнего вызова логируется. Как и на шаге добавления, +отображаемое имя SHALL влиять только на отображение и SHALL NOT влиять на пути +файлов, распознавание или раскладку. + +Обновление имени SHALL иметь две точки входа: **авто** — при подтверждённом +матче (см. capability `review`); **ручную** — по явному действию пользователя. +Ручное действие SHALL перезаписывать текущее имя всегда; авто SHALL перезаписывать, +когда выведенное имя непусто. + +#### Scenario: Перелив имени в загрузку и раздачу + +- **GIVEN** загрузка с распознанным непустым каноническим названием и известным + режиссёром (из матча или из сохранённого контекста) +- **WHEN** запускается обновление отображаемого имени +- **THEN** `download.display_name` устанавливается в полный ярлык + «Название (режиссёр, год)» (для сериала — со сводкой сезонов) +- **AND** раздача в qBittorrent переименовывается в то же имя (по infohash своей + раздачи) + +#### Scenario: Режиссёр из контекста переживает распознавание без матча + +- **GIVEN** загрузка, где режиссёр был извлечён из контекста, а распознавание + прошло без подтверждённого матча (режиссёр из метабазы недоступен) +- **WHEN** запускается обновление отображаемого имени +- **THEN** в ярлыке используется режиссёр из сохранённого контекста +- **AND** название/год берутся из распознавания + +#### Scenario: Режиссёр из матча бьёт контекстного + +- **GIVEN** загрузка, где режиссёр есть и в контексте, и в подтверждённом матче +- **WHEN** формируется ярлык +- **THEN** используется режиссёр из матча (более доверенный слой) + +#### Scenario: qBittorrent недоступен — имя у загрузки всё равно обновлено + +- **GIVEN** обновление отображаемого имени с выведенным непустым именем +- **WHEN** переименование раздачи в qBittorrent завершается ошибкой (недоступен + или раздача удалена) +- **THEN** `download.display_name` всё равно обновлён +- **AND** ошибка внешнего вызова qBittorrent логируется, операция не проваливается + +#### Scenario: Нет источника имени — обновление ничего не делает + +- **GIVEN** загрузка без распознанного названия и без сохранённой структуры + (пустой источник имени) +- **WHEN** запускается обновление отображаемого имени +- **THEN** ни `download.display_name`, ни имя раздачи не меняются (no-op) diff --git a/openspec/changes/archive/2026-07-11-field-resolution-display-name/specs/metadata-match/spec.md b/openspec/changes/archive/2026-07-11-field-resolution-display-name/specs/metadata-match/spec.md new file mode 100644 index 0000000..4924dcf --- /dev/null +++ b/openspec/changes/archive/2026-07-11-field-resolution-display-name/specs/metadata-match/spec.md @@ -0,0 +1,59 @@ +# metadata-match Specification + +## MODIFIED Requirements + +### Requirement: Подтверждение матча и каноническое имя + +При единичном сильном матче система SHALL брать из записи базы официальный +`provider` (`tmdb`|`tvdb`|`tvmaze`) и `provider_id`, а также каноническое название +и год, и подменять ими соответствующие поля плана (для сериала — с учётом внешнего +тега TVDB/IMDb из `externals`, идущего в имя папки). Матч SHALL считаться +подтверждённым только при ровно одном сильном кандидате; при нуле или нескольких +кандидатах подтверждённого матча быть SHALL NOT (авто-раскладка не разрешается, +кандидаты уходят в review). Работа с базами опциональна: при выключенных базах +сверка не выполняется и подтверждённого матча нет. + +При подтверждённом матче система SHALL дополнительно попытаться получить из базы +**режиссёра** (TMDB/TVDB credits) и вложить его в план (`director`) как +недоверенное косметическое значение для вывода отображаемого имени. Тот же способ +выборки режиссёра по `provider:id` SHALL быть доступен при закреплении вручную +выбранного в ревью кандидата (см. `review`), т.к. основной путь подтверждения +матча — ручной выбор, а не авто. Выборка режиссёра SHALL быть best-effort: её +недоступность, отсутствие в базе или провайдер без режиссёра (напр. TVMaze) SHALL +NOT проваливать распознавание/матч/выбор — `director` остаётся пустым, а имя +выводится без режиссёра или из более низкого слоя (сохранённый контекст). Режиссёр +из метабазы SHALL иметь приоритет над режиссёром из контекста (более проверенный +источник). + +Режиссёр — недоверенное человекочитаемое поле: он SHALL NOT участвовать в +структурной валидации/гейте авто-раскладки, а его очистка (управляющие символы, +пробелы, лимит длины) применяется при рендере отображаемого имени, а не в +plan-санитайзинге. + +#### Scenario: Единичный матч даёт id и каноническое имя + +- **GIVEN** поиск вернул ровно одного сильного кандидата TMDB для фильма +- **WHEN** матч подтверждается +- **THEN** план получает `provider`=`tmdb`, `provider_id`, каноническое название и год + +#### Scenario: Матч подтягивает режиссёра + +- **GIVEN** подтверждённый единичный матч TMDB для фильма, у которого в credits + указан режиссёр +- **WHEN** матч подтверждается +- **THEN** в план вкладывается `director` из credits +- **AND** отображаемое имя может использовать этого режиссёра + +#### Scenario: Режиссёр недоступен — матч не ломается + +- **GIVEN** подтверждённый матч, для которого выборка режиссёра недоступна или + провайдер режиссёра не отдаёт +- **WHEN** матч подтверждается +- **THEN** `director` остаётся пустым +- **AND** матч подтверждён, распознавание не проваливается + +#### Scenario: Несколько кандидатов — матч не подтверждён + +- **GIVEN** поиск вернул более одного подходящего кандидата +- **WHEN** оценивается матч +- **THEN** подтверждённого матча нет, кандидаты собираются для выбора в review diff --git a/openspec/changes/archive/2026-07-11-field-resolution-display-name/specs/recognition/spec.md b/openspec/changes/archive/2026-07-11-field-resolution-display-name/specs/recognition/spec.md new file mode 100644 index 0000000..f77f7d0 --- /dev/null +++ b/openspec/changes/archive/2026-07-11-field-resolution-display-name/specs/recognition/spec.md @@ -0,0 +1,44 @@ +# recognition Specification + +## MODIFIED Requirements + +### Requirement: Разбор сигналов LLM в структурированный план + +Система SHALL передавать LLM недоверенные сигналы (имя торрента, дерево файлов с +размерами, текстовый контекст и накопленные подсказки, пред-парс) и получать +структурированный план в схеме: `type` (`movie`|`series`), `title`, +`original_title`, `year`, `provider_hint`, `files[]` и `confidence`. Каждый +элемент `files[]` SHALL нести `src`, `role` +(`main`|`episode`|`subtitle`|`extra`|`sample`|`ignore`) и, для сериала, +per-file `season`/`episode` (отдельного скалярного `season` быть SHALL NOT — так +выражаются мультисезонные паки и спецвыпуски). План SHALL приниматься только +если каждый `files[].src` совпадает с реальным файлом торрента. + +План MAY дополнительно нести опциональное скалярное поле `director` (режиссёр). +Это поле НЕ требуется от LLM и НЕ участвует в структурной валидации или гейте +авто-раскладки; его заполняют подтверждённый матч метабазы (авто) или закреплённый +в ревью выбранный источник (через override, см. `metadata-match`/`review`) как +недоверенное косметическое значение для вывода отображаемого имени. Как недоверенное +человекочитаемое поле, `director` SHALL NOT входить в plan-санитайзинг (он чистит +`title`/`original_title`/`provider_hint`); очистка режиссёра применяется при рендере +имени. Пустой `director` SHALL быть штатным (режиссёр неизвестен). + +#### Scenario: План сериала с per-file нумерацией + +- **GIVEN** сезон-пак из 10 видеофайлов +- **WHEN** LLM возвращает план +- **THEN** `type` = `series`, а каждый видеофайл несёт свои `season`/`episode` + +#### Scenario: Несуществующий src отклоняется + +- **GIVEN** ответ LLM, где `files[].src` не совпадает ни с одним файлом торрента +- **WHEN** план разбирается +- **THEN** такой план не принимается как валидный + +#### Scenario: Режиссёр не требуется от LLM и не влияет на гейт + +- **GIVEN** ответ LLM без поля `director` +- **WHEN** план разбирается и оценивается +- **THEN** разбор успешен, `director` пуст +- **AND** отсутствие режиссёра не влияет на структурную валидацию и решение + auto/review diff --git a/openspec/changes/archive/2026-07-11-field-resolution-display-name/specs/review/spec.md b/openspec/changes/archive/2026-07-11-field-resolution-display-name/specs/review/spec.md new file mode 100644 index 0000000..795be72 --- /dev/null +++ b/openspec/changes/archive/2026-07-11-field-resolution-display-name/specs/review/spec.md @@ -0,0 +1,105 @@ +# review Specification + +## MODIFIED Requirements + +### Requirement: Подтверждение матча обновляет отображаемое имя + +Система SHALL при подтверждении матча в ревью запускать обновление отображаемого +имени загрузки по подтверждённому распознаванию (см. capability `ingest`): +переливать **полный ярлык** имени — «Название (режиссёр, год)», для сериала со +сводкой сезонов — в `download.display_name` и в имя раздачи qBittorrent, без +нового вызова LLM. Имя строится из эффективных полей (override → распознавание с +вложенным матчем → сохранённый контекст). Подтверждением матча SHALL +считаться как выбор кандидата из списка совпадений, так и ручное добавление +источника по id/URL (оба закрепляют провайдера и каноническое название). + +При закреплении выбранного/добавленного источника система SHALL best-effort +получить режиссёра этого источника из метабазы (credits по `provider:id`, см. +`metadata-match`) и закрепить его как override, чтобы он попал в эффективные поля +и в ярлык. Недоступность credits или отсутствие режиссёра SHALL NOT проваливать +выбор источника: режиссёр остаётся из более низкого слоя (сохранённый контекст) +или пустым. Так режиссёр из метабазы появляется и на **основном** пути +подтверждения — ручном выборе кандидата, а не только при авто-матче. + +Обновление SHALL выполняться после успешного закрепления выбора кандидата и +SHALL быть best-effort по отношению к qBittorrent: недоступность клиента SHALL +NOT проваливать команду ревью. Это согласуется с инвариантом «авто-действие +только при подтверждённом матче». + +#### Scenario: Выбор кандидата обновляет имя + +- **GIVEN** загрузка в ревью с пустым или неинформативным `display_name` + (например, «Unknown») и списком кандидатов +- **WHEN** пользователь выбирает кандидата, подтверждая матч +- **THEN** выбор кандидата закрепляется как и прежде +- **AND** `download.display_name` обновляется полным ярлыком + «Название (режиссёр, год)» (для сериала — со сводкой сезонов) +- **AND** раздача в qBittorrent переименовывается в то же имя + +#### Scenario: Ручное добавление источника обновляет имя + +- **GIVEN** загрузка в ревью без совпадений в списке +- **WHEN** пользователь вручную добавляет источник по id/URL, подтверждая матч +- **THEN** источник закрепляется как и прежде +- **AND** `download.display_name` и имя раздачи в qBittorrent обновляются + полным ярлыком подтверждённого источника + +#### Scenario: Выбор кандидата подтягивает режиссёра в ярлык + +- **GIVEN** загрузка в ревью, у выбранного кандидата в credits метабазы указан + режиссёр +- **WHEN** пользователь выбирает кандидата, подтверждая матч +- **THEN** режиссёр best-effort извлекается из метабазы и закрепляется override +- **AND** `download.display_name` получает полный ярлык с этим режиссёром + +#### Scenario: Режиссёр кандидата недоступен — выбор не ломается + +- **GIVEN** выбор кандидата, для которого credits недоступны или режиссёра нет +- **WHEN** пользователь подтверждает матч +- **THEN** выбор источника выполнен, режиссёр берётся из сохранённого контекста + или остаётся пустым +- **AND** команда ревью не возвращает ошибку + +#### Scenario: Недоступность qBittorrent не ломает выбор кандидата + +- **GIVEN** выбор кандидата в ревью +- **WHEN** переименование раздачи в qBittorrent завершается ошибкой +- **THEN** выбор кандидата и обновление `download.display_name` выполнены +- **AND** команда ревью не возвращает ошибку + +### Requirement: Инфо и предпросмотр выбранного источника + +В едином блоке выбора источника экран ревью SHALL показывать для **выбранного +(активного)** источника две части: **инфо** — тип (read-only, movie/series), +название, оригинальное название, год, режиссёра (из подтверждённого матча/ +кандидата, когда доступен; иначе пусто/прочерк, не ломая вёрстку), для сериала — +сводку сезонов (один сезон, диапазон/список для многосезонного пака или +«Спецвыпуски»); и **предпросмотр раскладки** — целевые пути хардлинков этого +источника. Обе части SHALL относиться именно к активному источнику и SHALL +обновляться при смене выбора. Отрисовка блока (показ инфо и предпросмотра) MUST +NOT создавать хардлинки: раскладка создаётся только явным действием «Применить». +Совпадение целевых путей предпросмотра с результатом применения регулируется +требованием «Превью раскладки через единую логику именования» (`web-ui`). + +#### Scenario: Инфо и предпросмотр относятся к активному источнику + +- **GIVEN** в списке активен кандидат метабазы +- **WHEN** пользователь смотрит инфо-часть и предпросмотр раскладки +- **THEN** показаны тип, название, ориг. название, год (и сводка сезонов для + сериала) именно этого источника и предпросмотр его целевых путей + +#### Scenario: Просмотр блока не создаёт раскладку + +- **GIVEN** экран ревью с показанным блоком выбора источника +- **WHEN** пользователь только просматривает инфо и предпросмотр, не нажимая + «Применить» +- **THEN** хардлинки не создаются, файлы под `paths.movies`/`series` не + меняются + +#### Scenario: Режиссёр показан, когда доступен + +- **GIVEN** активный источник — подтверждённый матч, несущий режиссёра +- **WHEN** отображается инфо-часть выбранного источника +- **THEN** в ней показан режиссёр этого источника +- **AND** при отсутствии режиссёра место остаётся пустым (или прочерком), не + ломая вёрстку diff --git a/openspec/changes/archive/2026-07-11-field-resolution-display-name/tasks.md b/openspec/changes/archive/2026-07-11-field-resolution-display-name/tasks.md new file mode 100644 index 0000000..aa67886 --- /dev/null +++ b/openspec/changes/archive/2026-07-11-field-resolution-display-name/tasks.md @@ -0,0 +1,71 @@ +## 1. Хранение контекста (миграция + store) + +- [x] 1.1 Миграция goose (`internal/store/migrations`): `ALTER TABLE download ADD + COLUMN parsed_context TEXT NOT NULL DEFAULT ''` (down — дроп колонки) +- [x] 1.2 `store.Download`: поле `ParsedContext string \`db:"parsed_context"\``; + включить колонку в SELECT/INSERT (`internal/store/download.go`, `list.go`) +- [x] 1.3 Метод `SetParsedContext(ctx, id, json string) error` (best-effort + апдейт) в store; добавить в интерфейс worker +- [x] 1.4 Обновить ER-схему `docs/specs/database.md` (новая колонка) + +## 2. Извлечение из контекста → persistent + +- [x] 2.1 `internal/naming`: экспортировать извлечённую структуру и её JSON + (публичный тип полей + метод/функция, возвращающая структуру вместе с именем), + не ломая текущий `DeriveName` +- [x] 2.2 `worker.go` шаг добавления: сохранять извлечённую структуру в + `download.parsed_context` (best-effort; сбой логируется, добавление не валит); + пустая структура → пустой `parsed_context` + +## 3. Режиссёр из метабазы (решение A2) + +- [x] 3.1 `metadata.Provider`: метод выборки режиссёра по id (credits) — + реализовать в клиентах TMDB и TVDB (best-effort); TVMaze → пусто/не поддержан +- [x] 3.2 `recognize.Plan`: поле `Director \`json:"director,omitempty"\``; LLM его + не заполняет, в валидации/гейте/plan-санитайзинге не участвует +- [x] 3.3 `Recognize`/`matchMetadata`: при подтверждённом авто-матче вкладывать + режиссёра в `plan.Director` (как уже вкладываются title/year), best-effort +- [x] 3.4 Интерфейс `Recognizer` (worker): метод выборки режиссёра по + `(provider, id)` — проброс к metadata-провайдерам (без прямой зависимости + worker→metadata) +- [x] 3.5 Ручной путь ревью: новое override-поле `ovrDirector`; добавить его в + `sourcePins`; `chooseCandidateLocked`/`AddManualSource` best-effort тянут + режиссёра выбранного `provider:id` и пишут в `ovrDirector` (пусто → очищает пин) +- [x] 3.6 `applyOverrides`: класть `ovrDirector` в `plan.Director` + +## 4. Единый рендер ярлыка + слоистое разрешение + +- [x] 4.1 Вынести логику `seasonSummary` (`httpapi/files.go`) в переиспользуемое + место (`recognize` или `naming`); `httpapi` зовёт общий хелпер (поведение + карточки не меняется) +- [x] 4.2 `internal/naming`: публичная функция рендера полного ярлыка из + эффективных полей (title/director/year + **готовая строка сводки сезонов**); + удалить/перевести `FormatTitleYear` +- [x] 4.3 `worker`: хелпер `effectiveNameFields(download, plan, overrides)` — + первый непустой слой override → recognition(+match) → parsed_context на поле; + сводка сезонов: `seasonSummary(plan)` при наличии episode-ролей, иначе fallback + на контекстный скаляр `Season` → «Сезон N»; director: override → plan → context +- [x] 4.4 `refreshDisplayNameLocked`: заменить `FormatTitleYear(...)` на рендер по + эффективным полям; сохранить best-effort qBit rename, no-op при пустом источнике +- [x] 4.5 Сверить, что старт (`DeriveName`) использует тот же рендер (единый + формат ярлыка на всех путях) + +## 5. UI ревью: режиссёр в инфо-части + +- [x] 5.1 `httpapi` инфо-часть выбранного источника: показывать режиссёра из + эффективного плана/кандидата, пустой → прочерк (без слома вёрстки) + +## 6. Тесты и проверки + +- [x] 6.1 Юнит-тесты рендера ярлыка: полный формат, опциональность режиссёра/года, + сводка сезонов (один/несколько/спецвыпуски), санитайзинг+лимит +- [x] 6.2 Тест слоистого разрешения: контекст-режиссёр переживает распознавание без + матча; матч-режиссёр бьёт контекстного; override бьёт оба +- [x] 6.3 Тест persist `parsed_context` на старте + best-effort (сбой не валит + добавление) +- [x] 6.4 Тест метабазного режиссёра: авто-матч TMDB/TVDB → `plan.Director`; + ручной выбор кандидата тянет режиссёра в `ovrDirector`; недоступность credits → + пусто, матч/выбор не падают +- [x] 6.5 `task lint` и `task test` зелёные; `openspec validate --strict` +- [x] 6.6 Удалить закрытую беклог-задачу + `docs/backlog/knopka-obnovit-imya-polnyj-format.md` и строку в индексе беклога diff --git a/openspec/specs/ingest/spec.md b/openspec/specs/ingest/spec.md index c1d8fc5..b9957ba 100644 --- a/openspec/specs/ingest/spec.md +++ b/openspec/specs/ingest/spec.md @@ -132,13 +132,24 @@ JSON-вывод), извлекая из контекста тип (movie/series) распознавание дало каноническое название, — переливая уже вычисленное имя (без нового вызова LLM) в `download.display_name` и в имя раздачи qBittorrent. -Источником имени SHALL быть **эффективное** распознанное название: пины -`title`/`year` (если матч подтверждён), иначе `recognition.title`/`year` — то -же название, что использует раскладка. Формат SHALL быть коротким детерминированным -ярлыком `Title (Year)` (год опционален), с той же очисткой от управляющих -символов и обрезкой по длине, что и вывод имени на шаге добавления. Пустой -источник (нет распознавания или пустое название) SHALL приводить к отсутствию -изменений (no-op). +Источником имени SHALL быть **эффективные поля имени**, разрешённые по слоям +сверху вниз (берётся первый непустой слой): (1) ручные правки `override`; +(2) распознавание с вложенным подтверждённым матчем (`recognition`, куда матч +метабазы уже вложил каноничные название/год/режиссёра); (3) сохранённая +структура из контекста (`download.parsed_context`). Так каждое поле берётся из +самого доверенного доступного источника, а данные из контекста (например +режиссёр) не теряются, если распознавание/матч их не дали. Название из слоя +распознавания SHALL совпадать с тем, что использует раскладка (эффективный +`title`), чтобы отображаемое имя не расходилось с целевыми путями. + +Формат SHALL быть тем же полным детерминированным ярлыком, что и на шаге +добавления: «Название (режиссёр, год)», где режиссёр и год опциональны, а для +сериала добавляется сводка сезонов («. Сезон N» для одного сезона; «. Сезоны …» +для многосезонного пака; отметка спецвыпусков) — согласованная со сводкой сезонов +на экране просмотра. Применяются та же очистка от управляющих символов и обрезка +по ограничению длины. Пустой источник (нет ни распознавания, ни сохранённой +структуры, дающих непустое название) SHALL приводить к отсутствию изменений +(no-op). Переименование раздачи в qBittorrent SHALL адресоваться по infohash своей раздачи и SHALL быть best-effort: сбой (раздача удалена, qBittorrent недоступен) @@ -154,12 +165,28 @@ SHALL NOT проваливать обновление — `download.display_name #### Scenario: Перелив имени в загрузку и раздачу -- **GIVEN** загрузка с распознанным непустым каноническим названием +- **GIVEN** загрузка с распознанным непустым каноническим названием и известным + режиссёром (из матча или из сохранённого контекста) - **WHEN** запускается обновление отображаемого имени -- **THEN** `download.display_name` устанавливается в `Title (Year)` +- **THEN** `download.display_name` устанавливается в полный ярлык + «Название (режиссёр, год)» (для сериала — со сводкой сезонов) - **AND** раздача в qBittorrent переименовывается в то же имя (по infohash своей раздачи) +#### Scenario: Режиссёр из контекста переживает распознавание без матча + +- **GIVEN** загрузка, где режиссёр был извлечён из контекста, а распознавание + прошло без подтверждённого матча (режиссёр из метабазы недоступен) +- **WHEN** запускается обновление отображаемого имени +- **THEN** в ярлыке используется режиссёр из сохранённого контекста +- **AND** название/год берутся из распознавания + +#### Scenario: Режиссёр из матча бьёт контекстного + +- **GIVEN** загрузка, где режиссёр есть и в контексте, и в подтверждённом матче +- **WHEN** формируется ярлык +- **THEN** используется режиссёр из матча (более доверенный слой) + #### Scenario: qBittorrent недоступен — имя у загрузки всё равно обновлено - **GIVEN** обновление отображаемого имени с выведенным непустым именем @@ -168,9 +195,10 @@ SHALL NOT проваливать обновление — `download.display_name - **THEN** `download.display_name` всё равно обновлён - **AND** ошибка внешнего вызова qBittorrent логируется, операция не проваливается -#### Scenario: Нет распознавания — обновление ничего не делает +#### Scenario: Нет источника имени — обновление ничего не делает -- **GIVEN** загрузка без распознанного названия (пустой источник имени) +- **GIVEN** загрузка без распознанного названия и без сохранённой структуры + (пустой источник имени) - **WHEN** запускается обновление отображаемого имени - **THEN** ни `download.display_name`, ни имя раздачи не меняются (no-op) @@ -561,3 +589,43 @@ NOT. - **WHEN** принимаемый `.torrent`-файл превышает ограничение размера - **THEN** приём отклоняется с ошибкой, загрузка не создаётся +### Requirement: Сохранение извлечённой из контекста структуры имени + +Система SHALL сохранять структуру имени, извлечённую LLM со структурированным +выводом на шаге добавления (тип, название, оригинальное название, год, режиссёр, +сезон), у загрузки (`download.parsed_context`, JSON), чтобы её поля могли +переиспользоваться при последующем выводе имени без повторного вызова LLM. +Алгоритмический фолбек структуры не даёт (его выход — только строка имени), тогда +`parsed_context` остаётся пустым — это штатно (нижний слой отсутствует). Сохранённая структура SHALL +быть **базовым (наименее доверенным) слоем** источника полей имени: её значения +берутся, только если более доверенный слой (распознавание/матч, ручные правки) +соответствующего поля не дал. + +Сохранение SHALL быть best-effort и косметическим: неудача записи `parsed_context` +SHALL NOT проваливать добавление загрузки, а сама структура SHALL влиять только на +отображаемое имя и SHALL NOT влиять на пути файлов, распознавание или раскладку. +Пустая/невыведенная структура (нет контекста и подсказки) SHALL приводить к +пустому `parsed_context` (нечего сохранять). + +#### Scenario: Извлечённый режиссёр сохраняется у загрузки + +- **GIVEN** контекст загрузки, из которого LLM извлёк режиссёра и год +- **WHEN** система выводит имя на шаге добавления +- **THEN** извлечённая структура (в т.ч. режиссёр) сохраняется в + `download.parsed_context` +- **AND** отображаемое имя формируется как и прежде (полный ярлык) + +#### Scenario: Сбой сохранения структуры не валит добавление + +- **GIVEN** запись `parsed_context` завершается ошибкой +- **WHEN** идёт шаг добавления загрузки +- **THEN** загрузка всё равно добавляется в qBittorrent (с `rename`, если имя + выведено) +- **AND** ошибка логируется, приём/добавление не проваливается + +#### Scenario: Пустой вход — пустая структура + +- **GIVEN** пойманная загрузка без контекста и без подсказки из полей источника +- **WHEN** выполняется шаг добавления +- **THEN** структура не выводится, `download.parsed_context` пуст + diff --git a/openspec/specs/metadata-match/spec.md b/openspec/specs/metadata-match/spec.md index 6e30436..903c25b 100644 --- a/openspec/specs/metadata-match/spec.md +++ b/openspec/specs/metadata-match/spec.md @@ -54,12 +54,45 @@ кандидаты уходят в review). Работа с базами опциональна: при выключенных базах сверка не выполняется и подтверждённого матча нет. +При подтверждённом матче система SHALL дополнительно попытаться получить из базы +**режиссёра** (TMDB/TVDB credits) и вложить его в план (`director`) как +недоверенное косметическое значение для вывода отображаемого имени. Тот же способ +выборки режиссёра по `provider:id` SHALL быть доступен при закреплении вручную +выбранного в ревью кандидата (см. `review`), т.к. основной путь подтверждения +матча — ручной выбор, а не авто. Выборка режиссёра SHALL быть best-effort: её +недоступность, отсутствие в базе или провайдер без режиссёра (напр. TVMaze) SHALL +NOT проваливать распознавание/матч/выбор — `director` остаётся пустым, а имя +выводится без режиссёра или из более низкого слоя (сохранённый контекст). Режиссёр +из метабазы SHALL иметь приоритет над режиссёром из контекста (более проверенный +источник). + +Режиссёр — недоверенное человекочитаемое поле: он SHALL NOT участвовать в +структурной валидации/гейте авто-раскладки, а его очистка (управляющие символы, +пробелы, лимит длины) применяется при рендере отображаемого имени, а не в +plan-санитайзинге. + #### Scenario: Единичный матч даёт id и каноническое имя - **GIVEN** поиск вернул ровно одного сильного кандидата TMDB для фильма - **WHEN** матч подтверждается - **THEN** план получает `provider`=`tmdb`, `provider_id`, каноническое название и год +#### Scenario: Матч подтягивает режиссёра + +- **GIVEN** подтверждённый единичный матч TMDB для фильма, у которого в credits + указан режиссёр +- **WHEN** матч подтверждается +- **THEN** в план вкладывается `director` из credits +- **AND** отображаемое имя может использовать этого режиссёра + +#### Scenario: Режиссёр недоступен — матч не ломается + +- **GIVEN** подтверждённый матч, для которого выборка режиссёра недоступна или + провайдер режиссёра не отдаёт +- **WHEN** матч подтверждается +- **THEN** `director` остаётся пустым +- **AND** матч подтверждён, распознавание не проваливается + #### Scenario: Несколько кандидатов — матч не подтверждён - **GIVEN** поиск вернул более одного подходящего кандидата diff --git a/openspec/specs/recognition/spec.md b/openspec/specs/recognition/spec.md index 186faa5..94defe2 100644 --- a/openspec/specs/recognition/spec.md +++ b/openspec/specs/recognition/spec.md @@ -59,6 +59,15 @@ per-file `season`/`episode` (отдельного скалярного `season` выражаются мультисезонные паки и спецвыпуски). План SHALL приниматься только если каждый `files[].src` совпадает с реальным файлом торрента. +План MAY дополнительно нести опциональное скалярное поле `director` (режиссёр). +Это поле НЕ требуется от LLM и НЕ участвует в структурной валидации или гейте +авто-раскладки; его заполняют подтверждённый матч метабазы (авто) или закреплённый +в ревью выбранный источник (через override, см. `metadata-match`/`review`) как +недоверенное косметическое значение для вывода отображаемого имени. Как недоверенное +человекочитаемое поле, `director` SHALL NOT входить в plan-санитайзинг (он чистит +`title`/`original_title`/`provider_hint`); очистка режиссёра применяется при рендере +имени. Пустой `director` SHALL быть штатным (режиссёр неизвестен). + #### Scenario: План сериала с per-file нумерацией - **GIVEN** сезон-пак из 10 видеофайлов @@ -71,6 +80,14 @@ per-file `season`/`episode` (отдельного скалярного `season` - **WHEN** план разбирается - **THEN** такой план не принимается как валидный +#### Scenario: Режиссёр не требуется от LLM и не влияет на гейт + +- **GIVEN** ответ LLM без поля `director` +- **WHEN** план разбирается и оценивается +- **THEN** разбор успешен, `director` пуст +- **AND** отсутствие режиссёра не влияет на структурную валидацию и решение + auto/review + ### Requirement: Провайдер LLM за абстракцией со структурированным выводом Доступ к LLM SHALL быть за интерфейсом с выбором реализации по полю `[llm].type` diff --git a/openspec/specs/review/spec.md b/openspec/specs/review/spec.md index c70802b..634a826 100644 --- a/openspec/specs/review/spec.md +++ b/openspec/specs/review/spec.md @@ -191,11 +191,21 @@ qBittorrent, но и **готов к раскладке** — раздача в Система SHALL при подтверждении матча в ревью запускать обновление отображаемого имени загрузки по подтверждённому распознаванию (см. capability `ingest`): -переливать каноническое название (`Title (Year)`) в `download.display_name` и в -имя раздачи qBittorrent, без нового вызова LLM. Подтверждением матча SHALL +переливать **полный ярлык** имени — «Название (режиссёр, год)», для сериала со +сводкой сезонов — в `download.display_name` и в имя раздачи qBittorrent, без +нового вызова LLM. Имя строится из эффективных полей (override → распознавание с +вложенным матчем → сохранённый контекст). Подтверждением матча SHALL считаться как выбор кандидата из списка совпадений, так и ручное добавление источника по id/URL (оба закрепляют провайдера и каноническое название). +При закреплении выбранного/добавленного источника система SHALL best-effort +получить режиссёра этого источника из метабазы (credits по `provider:id`, см. +`metadata-match`) и закрепить его как override, чтобы он попал в эффективные поля +и в ярлык. Недоступность credits или отсутствие режиссёра SHALL NOT проваливать +выбор источника: режиссёр остаётся из более низкого слоя (сохранённый контекст) +или пустым. Так режиссёр из метабазы появляется и на **основном** пути +подтверждения — ручном выборе кандидата, а не только при авто-матче. + Обновление SHALL выполняться после успешного закрепления выбора кандидата и SHALL быть best-effort по отношению к qBittorrent: недоступность клиента SHALL NOT проваливать команду ревью. Это согласуется с инвариантом «авто-действие @@ -207,8 +217,8 @@ NOT проваливать команду ревью. Это согласует (например, «Unknown») и списком кандидатов - **WHEN** пользователь выбирает кандидата, подтверждая матч - **THEN** выбор кандидата закрепляется как и прежде -- **AND** `download.display_name` обновляется каноническим именем кандидата - `Title (Year)` +- **AND** `download.display_name` обновляется полным ярлыком + «Название (режиссёр, год)» (для сериала — со сводкой сезонов) - **AND** раздача в qBittorrent переименовывается в то же имя #### Scenario: Ручное добавление источника обновляет имя @@ -217,7 +227,23 @@ NOT проваливать команду ревью. Это согласует - **WHEN** пользователь вручную добавляет источник по id/URL, подтверждая матч - **THEN** источник закрепляется как и прежде - **AND** `download.display_name` и имя раздачи в qBittorrent обновляются - каноническим названием подтверждённого источника + полным ярлыком подтверждённого источника + +#### Scenario: Выбор кандидата подтягивает режиссёра в ярлык + +- **GIVEN** загрузка в ревью, у выбранного кандидата в credits метабазы указан + режиссёр +- **WHEN** пользователь выбирает кандидата, подтверждая матч +- **THEN** режиссёр best-effort извлекается из метабазы и закрепляется override +- **AND** `download.display_name` получает полный ярлык с этим режиссёром + +#### Scenario: Режиссёр кандидата недоступен — выбор не ломается + +- **GIVEN** выбор кандидата, для которого credits недоступны или режиссёра нет +- **WHEN** пользователь подтверждает матч +- **THEN** выбор источника выполнен, режиссёр берётся из сохранённого контекста + или остаётся пустым +- **AND** команда ревью не возвращает ошибку #### Scenario: Недоступность qBittorrent не ломает выбор кандидата @@ -230,15 +256,15 @@ NOT проваливать команду ревью. Это согласует В едином блоке выбора источника экран ревью SHALL показывать для **выбранного (активного)** источника две части: **инфо** — тип (read-only, movie/series), -название, оригинальное название, год, для сериала — сводку сезонов (один сезон, -диапазон/список для многосезонного пака или «Спецвыпуски»), с -зарезервированным местом под режиссёра; и **предпросмотр раскладки** — целевые -пути хардлинков этого источника. Обе части SHALL относиться именно к активному -источнику и SHALL обновляться при смене выбора. Отрисовка блока (показ инфо и -предпросмотра) MUST NOT создавать хардлинки: раскладка создаётся только явным -действием «Применить». Совпадение целевых путей предпросмотра с результатом -применения регулируется требованием «Превью раскладки через единую логику -именования» (`web-ui`). +название, оригинальное название, год, режиссёра (из подтверждённого матча/ +кандидата, когда доступен; иначе пусто/прочерк, не ломая вёрстку), для сериала — +сводку сезонов (один сезон, диапазон/список для многосезонного пака или +«Спецвыпуски»); и **предпросмотр раскладки** — целевые пути хардлинков этого +источника. Обе части SHALL относиться именно к активному источнику и SHALL +обновляться при смене выбора. Отрисовка блока (показ инфо и предпросмотра) MUST +NOT создавать хардлинки: раскладка создаётся только явным действием «Применить». +Совпадение целевых путей предпросмотра с результатом применения регулируется +требованием «Превью раскладки через единую логику именования» (`web-ui`). #### Scenario: Инфо и предпросмотр относятся к активному источнику @@ -255,12 +281,13 @@ NOT проваливать команду ревью. Это согласует - **THEN** хардлинки не создаются, файлы под `paths.movies`/`series` не меняются -#### Scenario: Зарезервированное место под режиссёра +#### Scenario: Режиссёр показан, когда доступен -- **GIVEN** режиссёр из метабазы пока не загружается +- **GIVEN** активный источник — подтверждённый матч, несущий режиссёра - **WHEN** отображается инфо-часть выбранного источника -- **THEN** в ней присутствует место под режиссёра, показанное пустым (или - прочерком), не ломая вёрстку +- **THEN** в ней показан режиссёр этого источника +- **AND** при отсутствии режиссёра место остаётся пустым (или прочерком), не + ломая вёрстку ### Requirement: Разделение труда транспортов в ревью diff --git a/web/templates/partials/review_source_block.html b/web/templates/partials/review_source_block.html index 7ff403e..abfdc6e 100644 --- a/web/templates/partials/review_source_block.html +++ b/web/templates/partials/review_source_block.html @@ -56,7 +56,7 @@ {{if .OriginalTitle}}
Ориг. название
{{.OriginalTitle}}
{{end}} {{if .Year}}
Год
{{.Year}}
{{end}} {{if .IsSeries}}
Сезоны
{{if .SeasonSummary}}{{.SeasonSummary}}{{else}}{{end}}
{{end}} -
Режиссёр
+
Режиссёр
{{if .Director}}{{.Director}}{{else}}{{end}}