Правки: дата на стр. загрузки, ссылка TVDB по типу, заголовки в Telegram
- Веб-UI: под шапкой страницы загрузки — дата добавления и относительная давность («N дней назад»), как в карточках списка. - Баг: ссылка-dereferrer TVDB для фильма вела на /series/; теперь строится по типу запроса (/movie/ либо /series/). Тест + правка спеки metadata-match. - Telegram: заголовки уведомлений (готово/ошибка/рассинхрон) берутся из display_name — консистентно с веб-UI; сезон подтягивается автоматически. - Telegram: в сообщениях об ошибке — и заголовок, и #id загрузки (ULID) для быстрого поиска по логам. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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-свопа блока источника
|
### Панель действий ревью вне htmx-свопа блока источника
|
||||||
|
|
||||||
При выборе источника одним кликом обновляется только блок источника
|
При выборе источника одним кликом обновляется только блок источника
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
@@ -21,6 +22,8 @@ type downloadDetailView struct {
|
|||||||
State string
|
State string
|
||||||
Error string
|
Error string
|
||||||
Note string
|
Note string
|
||||||
|
Added string // дата добавления (source_added_at → created_at), как в списке
|
||||||
|
AddedAgo string // относительная давность («5 дней назад»); пусто — если не распарсить
|
||||||
CreatedAt string
|
CreatedAt string
|
||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
|
|
||||||
@@ -100,6 +103,12 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
d.State == store.StateTargetMissing,
|
d.State == store.StateTargetMissing,
|
||||||
Retriable: d.State == store.StateFailed || d.State == store.StateStuck,
|
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 {
|
if rd.Recognition != nil {
|
||||||
view.HasPlan = len(rd.Plan.Files) > 0
|
view.HasPlan = len(rd.Plan.Files) > 0
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
|||||||
ID: r.TVDBID,
|
ID: r.TVDBID,
|
||||||
Title: r.Name,
|
Title: r.Name,
|
||||||
Year: year,
|
Year: year,
|
||||||
URL: "https://www.thetvdb.com/dereferrer/series/" + r.TVDBID,
|
URL: "https://www.thetvdb.com/dereferrer/" + typ + "/" + r.TVDBID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -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) {
|
func TestTVDB_SeasonEpisodeCounts(t *testing.T) {
|
||||||
srv := fakeTVDB(t, nil)
|
srv := fakeTVDB(t, nil)
|
||||||
counts, err := newTVDB(t, srv.URL).SeasonEpisodeCounts(context.Background(), "269613")
|
counts, err := newTVDB(t, srv.URL).SeasonEpisodeCounts(context.Background(), "269613")
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ const tid = "01arz3ndektsv4rrffq69g5fav"
|
|||||||
func reviewData(state store.State) *worker.ReviewData {
|
func reviewData(state store.State) *worker.ReviewData {
|
||||||
s, e := 2, 1
|
s, e := 2, 1
|
||||||
return &worker.ReviewData{
|
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{
|
Recognition: &store.Recognition{
|
||||||
Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
|
Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
|
||||||
Reasons: `["неполный пак"]`,
|
Reasons: `["неполный пак"]`,
|
||||||
@@ -255,7 +255,9 @@ func TestBot_NotifyDone(t *testing.T) {
|
|||||||
rev.data = reviewData(store.StateDone)
|
rev.data = reviewData(store.StateDone)
|
||||||
b.Notify(context.Background(), tid, worker.EventDone)
|
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)
|
t.Errorf("sent = %+v", api.sent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,7 +267,10 @@ func TestBot_NotifyFailed(t *testing.T) {
|
|||||||
rev.data = reviewData(store.StateFailed)
|
rev.data = reviewData(store.StateFailed)
|
||||||
b.Notify(context.Background(), tid, worker.EventFailed)
|
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)
|
t.Errorf("sent = %+v", api.sent)
|
||||||
}
|
}
|
||||||
if !api.sent[0].hasKB { // кнопка повтора
|
if !api.sent[0].hasKB { // кнопка повтора
|
||||||
|
|||||||
@@ -85,9 +85,19 @@ func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMark
|
|||||||
return &kb
|
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 — короткое сообщение о готовности.
|
// renderDone — короткое сообщение о готовности.
|
||||||
func (b *Bot) renderDone(rd *worker.ReviewData) string {
|
func (b *Bot) renderDone(rd *worker.ReviewData) string {
|
||||||
title := rd.Plan.Title
|
title := displayTitle(rd)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = "#" + rd.Download.ID
|
title = "#" + rd.Download.ID
|
||||||
}
|
}
|
||||||
@@ -100,7 +110,7 @@ func (b *Bot) renderDone(rd *worker.ReviewData) string {
|
|||||||
|
|
||||||
// renderDesync — уведомление о рассинхроне (источник/цель удалены вручную).
|
// renderDesync — уведомление о рассинхроне (источник/цель удалены вручную).
|
||||||
func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) string {
|
func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) string {
|
||||||
title := rd.Plan.Title
|
title := displayTitle(rd)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = "#" + rd.Download.ID
|
title = "#" + rd.Download.ID
|
||||||
}
|
}
|
||||||
@@ -122,7 +132,12 @@ func (b *Bot) renderFailed(rd *worker.ReviewData) (string, *tgbotapi.InlineKeybo
|
|||||||
if rd.Download.State == store.StateStuck {
|
if rd.Download.State == store.StateStuck {
|
||||||
verb = "зависла"
|
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)
|
fmt.Fprintf(&sb, "❌ Задача #%s %s", id, verb)
|
||||||
|
}
|
||||||
if code := rd.Download.ErrorCode.String; code != "" {
|
if code := rd.Download.ErrorCode.String; code != "" {
|
||||||
fmt.Fprintf(&sb, " (%s)", code)
|
fmt.Fprintf(&sb, " (%s)", code)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,9 @@
|
|||||||
- **TMDB**: `https://www.themoviedb.org/movie/{id}` (фильм) или
|
- **TMDB**: `https://www.themoviedb.org/movie/{id}` (фильм) или
|
||||||
`https://www.themoviedb.org/tv/{id}` (сериал) — тип контента известен из
|
`https://www.themoviedb.org/tv/{id}` (сериал) — тип контента известен из
|
||||||
запроса `Query.Type`
|
запроса `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 использовать
|
- **TVMaze**: `https://www.tvmaze.com/shows/{id}` — URL SHALL использовать
|
||||||
нативный id TVMaze, а не внешний тег (TVDB/IMDb), чтобы ссылка вела на
|
нативный id TVMaze, а не внешний тег (TVDB/IMDb), чтобы ссылка вела на
|
||||||
TVMaze-страницу
|
TVMaze-страницу
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
<span class="hash">{{.ID}}</span>
|
<span class="hash">{{.ID}}</span>
|
||||||
<button class="copy-btn" type="button" onclick="copyHash(this,'{{.ID}}')">⧉ копировать</button>
|
<button class="copy-btn" type="button" onclick="copyHash(this,'{{.ID}}')">⧉ копировать</button>
|
||||||
</div>
|
</div>
|
||||||
|
{{if .Added}}<p class="muted" style="margin:var(--sp-2) 0 0">Добавлено {{.Added}} <span class="ago">({{.AddedAgo}})</span></p>{{end}}
|
||||||
{{if .Note}}<p class="muted" style="margin:var(--sp-2) 0 0">{{.Note}}</p>{{end}}
|
{{if .Note}}<p class="muted" style="margin:var(--sp-2) 0 0">{{.Note}}</p>{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="card-acts" style="align-items:center">
|
<div class="card-acts" style="align-items:center">
|
||||||
|
|||||||
Reference in New Issue
Block a user