display_name: слоистое разрешение полей + сохранение режиссёра из контекста
Единый источник полей отображаемого имени и один рендер полного ярлыка на всех путях (старт и «Обновить имя»/авто-перелив). Раньше старт давал полный «Название (режиссёр, год). Сезон 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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) — не боль, из приоритета выпало
|
||||
|
||||
@@ -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`.
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
// Ручное обновление имени доступно, когда есть распознанное название,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+66
-52
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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), если есть, иначе сам провайдер
|
||||
// поиска. Используется и в матче, и при сохранении кандидатов.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 не даёт молча оживить терминальную задачу.
|
||||
|
||||
@@ -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;
|
||||
@@ -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 (добавлен ранее
|
||||
|
||||
@@ -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 }
|
||||
+80
-10
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-07-11
|
||||
@@ -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).
|
||||
@@ -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`.
|
||||
+121
@@ -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)
|
||||
+59
@@ -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
|
||||
+44
@@ -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
|
||||
+105
@@ -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** при отсутствии режиссёра место остаётся пустым (или прочерком), не
|
||||
ломая вёрстку
|
||||
@@ -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` и строку в индексе беклога
|
||||
@@ -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` пуст
|
||||
|
||||
|
||||
@@ -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** поиск вернул более одного подходящего кандидата
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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: Разделение труда транспортов в ревью
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
{{if .OriginalTitle}}<div class="field"><span class="label">Ориг. название</span><div class="mono">{{.OriginalTitle}}</div></div>{{end}}
|
||||
{{if .Year}}<div class="field"><span class="label">Год</span><div>{{.Year}}</div></div>{{end}}
|
||||
{{if .IsSeries}}<div class="field"><span class="label">Сезоны</span><div>{{if .SeasonSummary}}{{.SeasonSummary}}{{else}}<span class="faint">—</span>{{end}}</div></div>{{end}}
|
||||
<div class="field"><span class="label">Режиссёр</span><div><span class="faint">—</span></div></div>
|
||||
<div class="field"><span class="label">Режиссёр</span><div>{{if .Director}}{{.Director}}{{else}}<span class="faint">—</span>{{end}}</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Предпросмотр раскладки выбранного источника -->
|
||||
|
||||
Reference in New Issue
Block a user