diff --git a/docs/backlog.md b/docs/backlog.md index 3839580..237656e 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -364,30 +364,6 @@ auto/review»), ## Низкий -### Баг: ссылка на запись TVDB всегда `/series/` (для фильмов ведёт не туда) - -`metadata.TVDB.Search` знает тип запроса (`series`/`movie`, -[tvdb.go](../internal/metadata/tvdb.go) строки 156–159 — он идёт в API), но -URL кандидата хардкодит `.../dereferrer/series/{id}` (там же, строка 179) -**независимо от типа**. Для фильма ссылка ведёт на `/series/{id}` вместо -`/movie/{id}`, хотя идентификатор верный. Пример: «Последний единорог», id -3015 → отдаём `https://www.thetvdb.com/dereferrer/series/3015`, рабочая — -`https://www.thetvdb.com/dereferrer/movie/3015`. `matchURL`/`sourceMatchURL` -(`internal/httpapi/review.go`) предпочитают сохранённый URL кандидата над -`providerURL` (который тип учитывает верно), поэтому неверный `/series/` -виден в UI. Фикс — подставлять тип запроса в URL (одна строка); заодно -свериться, что `providerURL` не расходится по типам. - -> Аудит спек↔код (2026-07-03) подтвердил баг и выявил, что спека -> `metadata-match` теперь тоже «узаконивает» его: требование «Кандидат несёт -> URL» задаёт для TVDB единственный формат `/dereferrer/series/{id}` без -> различения типа. Фикс должен править и код, и это требование (movie-вариант -> по аналогии с TMDB). - -Связано: [recognition.md](specs/recognition.md) (сверка с базой, кандидаты), -`openspec/specs/metadata-match` (требование «Кандидат несёт URL»), пакеты -`metadata`, `httpapi`. - ### Панель действий ревью вне htmx-свопа блока источника При выборе источника одним кликом обновляется только блок источника diff --git a/internal/httpapi/download.go b/internal/httpapi/download.go index ce61fe2..0550323 100644 --- a/internal/httpapi/download.go +++ b/internal/httpapi/download.go @@ -4,6 +4,7 @@ import ( "errors" "net/http" "strconv" + "time" "git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/worker" @@ -21,6 +22,8 @@ type downloadDetailView struct { State string Error string Note string + Added string // дата добавления (source_added_at → created_at), как в списке + AddedAgo string // относительная давность («5 дней назад»); пусто — если не распарсить CreatedAt string UpdatedAt string @@ -100,6 +103,12 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) { d.State == store.StateTargetMissing, Retriable: d.State == store.StateFailed || d.State == store.StateStuck, } + // Дата добавления рядом с шапкой (source_added_at → фолбэк created_at, + // как в порядке и карточках списка); неразбираемое время просто опускаем. + if t, ok := addedTime(d); ok { + view.Added = fmtDate(t, s.deps.Loc) + view.AddedAgo = humanizeAge(t, time.Now()) + } if rd.Recognition != nil { view.HasPlan = len(rd.Plan.Files) > 0 diff --git a/internal/metadata/tvdb.go b/internal/metadata/tvdb.go index 2e47b88..7535e7c 100644 --- a/internal/metadata/tvdb.go +++ b/internal/metadata/tvdb.go @@ -176,7 +176,7 @@ func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) { ID: r.TVDBID, Title: r.Name, Year: year, - URL: "https://www.thetvdb.com/dereferrer/series/" + r.TVDBID, + URL: "https://www.thetvdb.com/dereferrer/" + typ + "/" + r.TVDBID, }) } return out, nil diff --git a/internal/metadata/tvdb_test.go b/internal/metadata/tvdb_test.go index 9dc3d8d..a852bbd 100644 --- a/internal/metadata/tvdb_test.go +++ b/internal/metadata/tvdb_test.go @@ -83,6 +83,30 @@ func TestTVDB_SearchAndLoginCached(t *testing.T) { } } +// Для фильма ссылка-dereferrer должна вести на /movie/, а не /series/. +func TestTVDB_MovieDereferrerURL(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/login", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"token":"tok"}}`)) + }) + mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("type") != "movie" { + t.Errorf("type = %q, want movie", r.URL.Query().Get("type")) + } + _, _ = w.Write([]byte(`{"data":[{"tvdb_id":"3015","name":"The Last Unicorn","year":"1982"}]}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + got, err := newTVDB(t, srv.URL).Search(context.Background(), Query{Type: Movie, Title: "The Last Unicorn"}) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(got) != 1 || got[0].URL != "https://www.thetvdb.com/dereferrer/movie/3015" { + t.Fatalf("candidate = %+v", got) + } +} + func TestTVDB_SeasonEpisodeCounts(t *testing.T) { srv := fakeTVDB(t, nil) counts, err := newTVDB(t, srv.URL).SeasonEpisodeCounts(context.Background(), "269613") diff --git a/internal/tgbot/bot_test.go b/internal/tgbot/bot_test.go index cc279eb..6c3a885 100644 --- a/internal/tgbot/bot_test.go +++ b/internal/tgbot/bot_test.go @@ -108,7 +108,7 @@ const tid = "01arz3ndektsv4rrffq69g5fav" func reviewData(state store.State) *worker.ReviewData { s, e := 2, 1 return &worker.ReviewData{ - Download: store.Download{ID: tid, State: state, Context: "Фарго, второй сезон", SourceRef: "magnet:?x"}, + Download: store.Download{ID: tid, State: state, DisplayName: "Фарго (2015). Сезон 2", Context: "Фарго, второй сезон", SourceRef: "magnet:?x"}, Recognition: &store.Recognition{ Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"), Reasons: `["неполный пак"]`, @@ -255,7 +255,9 @@ func TestBot_NotifyDone(t *testing.T) { rev.data = reviewData(store.StateDone) b.Notify(context.Background(), tid, worker.EventDone) - if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Готово") { + // Заголовок берётся из display_name (консистентно с веб-UI), а не из Plan.Title. + if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Готово") || + !strings.Contains(api.sent[0].text, "Фарго (2015). Сезон 2") { t.Errorf("sent = %+v", api.sent) } } @@ -265,7 +267,10 @@ func TestBot_NotifyFailed(t *testing.T) { rev.data = reviewData(store.StateFailed) b.Notify(context.Background(), tid, worker.EventFailed) - if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не удалась") { + // В ошибке — и заголовок (display_name), и #id для поиска по логам. + if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "не удалась") || + !strings.Contains(api.sent[0].text, "Фарго (2015). Сезон 2") || + !strings.Contains(api.sent[0].text, "#"+tid) { t.Errorf("sent = %+v", api.sent) } if !api.sent[0].hasKB { // кнопка повтора diff --git a/internal/tgbot/render.go b/internal/tgbot/render.go index 0506190..8dc21c1 100644 --- a/internal/tgbot/render.go +++ b/internal/tgbot/render.go @@ -85,9 +85,19 @@ func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMark return &kb } +// displayTitle — имя раздачи для уведомлений: имя раздачи (display_name, то, +// что ушло в qBittorrent и показывается в веб-UI) → распознанное название. +// Пусто, если ни того ни другого нет. Держим заголовки консистентными с UI. +func displayTitle(rd *worker.ReviewData) string { + if t := strings.TrimSpace(rd.Download.DisplayName); t != "" { + return t + } + return rd.Plan.Title +} + // renderDone — короткое сообщение о готовности. func (b *Bot) renderDone(rd *worker.ReviewData) string { - title := rd.Plan.Title + title := displayTitle(rd) if title == "" { title = "#" + rd.Download.ID } @@ -100,7 +110,7 @@ func (b *Bot) renderDone(rd *worker.ReviewData) string { // renderDesync — уведомление о рассинхроне (источник/цель удалены вручную). func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) string { - title := rd.Plan.Title + title := displayTitle(rd) if title == "" { title = "#" + rd.Download.ID } @@ -122,7 +132,12 @@ func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeybo if rd.Download.State == store.StateStuck { verb = "зависла" } - fmt.Fprintf(&sb, "❌ Задача #%s %s", id, verb) + // Заголовок (display_name) для читаемости + #id для поиска по логам. + if title := displayTitle(rd); title != "" { + fmt.Fprintf(&sb, "❌ «%s» — задача #%s %s", title, id, verb) + } else { + fmt.Fprintf(&sb, "❌ Задача #%s %s", id, verb) + } if code := rd.Download.ErrorCode.String; code != "" { fmt.Fprintf(&sb, " (%s)", code) } diff --git a/openspec/specs/metadata-match/spec.md b/openspec/specs/metadata-match/spec.md index 824209b..04d41ae 100644 --- a/openspec/specs/metadata-match/spec.md +++ b/openspec/specs/metadata-match/spec.md @@ -102,7 +102,9 @@ - **TMDB**: `https://www.themoviedb.org/movie/{id}` (фильм) или `https://www.themoviedb.org/tv/{id}` (сериал) — тип контента известен из запроса `Query.Type` -- **TVDB**: `https://www.thetvdb.com/dereferrer/series/{id}` +- **TVDB**: `https://www.thetvdb.com/dereferrer/movie/{id}` (фильм) или + `https://www.thetvdb.com/dereferrer/series/{id}` (сериал) — тип контента + известен из запроса `Query.Type` - **TVMaze**: `https://www.tvmaze.com/shows/{id}` — URL SHALL использовать нативный id TVMaze, а не внешний тег (TVDB/IMDb), чтобы ссылка вела на TVMaze-страницу diff --git a/web/templates/download.html b/web/templates/download.html index 3b4cd49..e36d45c 100644 --- a/web/templates/download.html +++ b/web/templates/download.html @@ -26,6 +26,7 @@ {{.ID}} + {{if .Added}}

Добавлено {{.Added}} ({{.AddedAgo}})

{{end}} {{if .Note}}

{{.Note}}

{{end}}