Ссылки на внешние базы в ревью (recognition)
Каждый кандидат внешней базы метаданных (TMDB/TVDB/TVMaze) теперь несёт URL на страницу элемента — при ревью можно кликнуть и проверить матч. URL формируется клиентом провайдера при поиске, сохраняется в БД (metadata_candidate.url) и отображается ссылкой в веб-интерфейсе. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,7 @@ erDiagram
|
||||
TEXT provider_id "NOT NULL"
|
||||
TEXT title "nullable"
|
||||
INTEGER year "nullable"
|
||||
TEXT url "nullable; ссылка на страницу на сайте провайдера"
|
||||
INTEGER chosen "NOT NULL DEFAULT 0; 0/1"
|
||||
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ type candidateView struct {
|
||||
ProviderID string
|
||||
Title string
|
||||
Year int
|
||||
URL string
|
||||
Chosen bool
|
||||
}
|
||||
|
||||
@@ -130,6 +131,7 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||
ProviderID: c.ProviderID,
|
||||
Title: c.Title.String,
|
||||
Year: int(c.Year.Int64),
|
||||
URL: c.URL.String,
|
||||
Chosen: c.Chosen,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ type Candidate struct {
|
||||
Title string
|
||||
OriginalTitle string
|
||||
Year int
|
||||
URL string // ссылка на страницу элемента на сайте провайдера
|
||||
TagProvider string // напр. "tvdb"/"imdb" (опц.)
|
||||
TagID string
|
||||
}
|
||||
|
||||
@@ -98,6 +98,10 @@ func (t *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
||||
return nil, fmt.Errorf("tmdb search: %w", err)
|
||||
}
|
||||
|
||||
webPath := "movie"
|
||||
if q.Type == Series {
|
||||
webPath = "tv"
|
||||
}
|
||||
out := make([]Candidate, 0, len(resp.Results))
|
||||
for _, r := range resp.Results {
|
||||
title, orig, date := r.Title, r.OriginalTitle, r.ReleaseDate
|
||||
@@ -110,6 +114,7 @@ func (t *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
||||
Title: title,
|
||||
OriginalTitle: orig,
|
||||
Year: yearOf(date),
|
||||
URL: "https://www.themoviedb.org/" + webPath + "/" + strconv.Itoa(r.ID),
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -42,6 +42,9 @@ func TestTMDB_SearchMovie(t *testing.T) {
|
||||
if c.Provider != "tmdb" || c.ID != "603" || c.Title != "The Matrix" || c.Year != 1999 {
|
||||
t.Errorf("candidate = %+v", c)
|
||||
}
|
||||
if c.URL != "https://www.themoviedb.org/movie/603" {
|
||||
t.Errorf("URL = %q", c.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTMDB_SearchSeries(t *testing.T) {
|
||||
@@ -65,6 +68,9 @@ func TestTMDB_SearchSeries(t *testing.T) {
|
||||
if len(got) != 1 || got[0].ID != "60622" || got[0].Title != "Fargo" || got[0].Year != 2014 {
|
||||
t.Errorf("candidate = %+v", got[0])
|
||||
}
|
||||
if got[0].URL != "https://www.themoviedb.org/tv/60622" {
|
||||
t.Errorf("URL = %q", got[0].URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTMDB_SeasonEpisodeCounts(t *testing.T) {
|
||||
|
||||
@@ -176,6 +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,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
|
||||
@@ -71,6 +71,9 @@ func TestTVDB_SearchAndLoginCached(t *testing.T) {
|
||||
if len(got) != 1 || got[0].ID != "269613" || got[0].Provider != "tvdb" || got[0].Year != 2014 {
|
||||
t.Fatalf("candidate = %+v", got)
|
||||
}
|
||||
if got[0].URL != "https://www.thetvdb.com/dereferrer/series/269613" {
|
||||
t.Fatalf("URL = %q", got[0].URL)
|
||||
}
|
||||
// Второй запрос переиспользует токен — повторного логина нет.
|
||||
if _, err := c.Search(context.Background(), Query{Type: Series, Title: "Fargo"}); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -82,6 +82,7 @@ func (t *TVMaze) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
||||
ID: strconv.Itoa(s.ID),
|
||||
Title: s.Name,
|
||||
Year: yearOf(s.Premiered),
|
||||
URL: "https://www.tvmaze.com/shows/" + strconv.Itoa(s.ID),
|
||||
}
|
||||
// Тег папки — привычный TVDB-id, если есть; иначе IMDb.
|
||||
switch {
|
||||
|
||||
@@ -41,6 +41,9 @@ func TestTVMaze_SearchSeries(t *testing.T) {
|
||||
if c.Provider != "tvmaze" || c.ID != "1" || c.Title != "Fargo" || c.Year != 2014 {
|
||||
t.Errorf("candidate = %+v", c)
|
||||
}
|
||||
if c.URL != "https://www.tvmaze.com/shows/1" {
|
||||
t.Errorf("URL = %q", c.URL)
|
||||
}
|
||||
// TVDB-id из externals → тег папки.
|
||||
if c.TagProvider != "tvdb" || c.TagID != "269613" {
|
||||
t.Errorf("tag = %s/%s, want tvdb/269613", c.TagProvider, c.TagID)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE metadata_candidate ADD COLUMN url TEXT;
|
||||
|
||||
-- +goose Down
|
||||
-- SQLite не умеет DROP COLUMN в старых версиях, но modernc.org/sqlite
|
||||
-- поддерживает ALTER TABLE DROP COLUMN начиная с 3.35.0.
|
||||
ALTER TABLE metadata_candidate DROP COLUMN url;
|
||||
@@ -271,6 +271,7 @@ type MetadataCandidate struct {
|
||||
ProviderID string `db:"provider_id"`
|
||||
Title sql.NullString `db:"title"`
|
||||
Year sql.NullInt64 `db:"year"`
|
||||
URL sql.NullString `db:"url"`
|
||||
Chosen bool `db:"chosen"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
}
|
||||
@@ -287,11 +288,11 @@ func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate)
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
const q = `
|
||||
INSERT INTO metadata_candidate (recognition_id, provider, provider_id, title, year)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
INSERT INTO metadata_candidate (recognition_id, provider, provider_id, title, year, url)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
for _, c := range cands {
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year); err != nil {
|
||||
c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year, c.URL); err != nil {
|
||||
return fmt.Errorf("insert candidate: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -785,6 +785,9 @@ func toStoreCandidates(recognitionID int64, cands []metadata.Candidate) []store.
|
||||
if c.Year != 0 {
|
||||
mc.Year = sql.NullInt64{Int64: int64(c.Year), Valid: true}
|
||||
}
|
||||
if c.URL != "" {
|
||||
mc.URL = store.NullString(c.URL)
|
||||
}
|
||||
out = append(out, mc)
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -1026,7 +1026,7 @@ func TestRecognizeOne_PersistsCandidates(t *testing.T) {
|
||||
}
|
||||
res := seriesResult()
|
||||
res.Candidates = []metadata.Candidate{
|
||||
{Provider: "tvmaze", ID: "1", Title: "Show A", Year: 2006, TagProvider: "tvdb", TagID: "269613"},
|
||||
{Provider: "tvmaze", ID: "1", Title: "Show A", Year: 2006, TagProvider: "tvdb", TagID: "269613", URL: "https://www.tvmaze.com/shows/1"},
|
||||
{Provider: "tvmaze", ID: "2", Title: "Show B", Year: 2007},
|
||||
}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{result: res}, nil)
|
||||
@@ -1040,6 +1040,14 @@ func TestRecognizeOne_PersistsCandidates(t *testing.T) {
|
||||
if st.candidates[0].Provider != "tvdb" || st.candidates[0].ProviderID != "269613" {
|
||||
t.Errorf("candidate[0] = %+v", st.candidates[0])
|
||||
}
|
||||
// URL первого кандидата сохранён.
|
||||
if st.candidates[0].URL.String != "https://www.tvmaze.com/shows/1" || !st.candidates[0].URL.Valid {
|
||||
t.Errorf("candidate[0].URL = (%q, valid=%v), want url", st.candidates[0].URL.String, st.candidates[0].URL.Valid)
|
||||
}
|
||||
// У второго кандидата URL не задан — в БД должен быть NULL (Valid=false).
|
||||
if st.candidates[1].URL.Valid {
|
||||
t.Error("candidate[1].URL must be NULL (Valid=false) for candidate without URL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseCandidate_PinsOverrides(t *testing.T) {
|
||||
@@ -1135,6 +1143,24 @@ func TestReviewData_IncludesCandidates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToStoreCandidates_URL(t *testing.T) {
|
||||
// Кандидат с URL: URL должен быть проброшен как непустой NullString.
|
||||
// Кандидат без URL: URL должен быть пустым NullString (Valid=false → NULL).
|
||||
candURL := toStoreCandidates(1, []metadata.Candidate{
|
||||
{Provider: "tmdb", ID: "603", Title: "With URL", URL: "https://www.themoviedb.org/movie/603"},
|
||||
{Provider: "tvdb", ID: "1", Title: "Without URL", URL: ""},
|
||||
})
|
||||
if len(candURL) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(candURL))
|
||||
}
|
||||
if c := candURL[0]; c.URL.String != "https://www.themoviedb.org/movie/603" || !c.URL.Valid {
|
||||
t.Errorf("URL[0] = (%q, valid=%v), want (url, true)", c.URL.String, c.URL.Valid)
|
||||
}
|
||||
if c := candURL[1]; c.URL.String != "" || c.URL.Valid {
|
||||
t.Errorf("URL[1] = (%q, valid=%v), want (\"\", false)", c.URL.String, c.URL.Valid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToLayoutPlan(t *testing.T) {
|
||||
s, e := 1, 3
|
||||
plan := recognize.Plan{
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-06-29
|
||||
@@ -0,0 +1,56 @@
|
||||
## Context
|
||||
|
||||
Сейчас `metadata.Candidate` и `store.MetadataCandidate` хранят провайдера и
|
||||
id, но не URL. Человек на ревью видит таблицу кандидатов с названием и id,
|
||||
но чтобы проверить матч — должен вручную открыть сайт базы и вставить id в
|
||||
поиск. Изменение добавляет поле `URL` на всех слоях: от клиентов метабаз до
|
||||
шаблона ревью.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Каждый кандидат внешней базы несёт URL, по которому человек может
|
||||
перейти и проверить матч
|
||||
- URL генерируется клиентом провайдера при поиске (source of truth)
|
||||
- Сохраняется в БД и отображается в веб-интерфейсе ревью
|
||||
|
||||
**Non-Goals:**
|
||||
- Не меняем Telegram-бот (в чате кандидатов нет, выбор — через веб)
|
||||
- Не добавляем отдельную колонку для нативного провайдера (всегда можно
|
||||
вывести из URL или добавить позже)
|
||||
- Не кешируем и не валидируем URL (не наша ответственность)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. URL генерируется клиентом провайдера при Search, а не на лету при отображении
|
||||
|
||||
**Почему:** клиент знает и нативный провайдер, и нативный id. При
|
||||
отображении в ревью мы имеем только теговые provider/provider_id (TVMaze →
|
||||
tvdb/269613), и нативный tvMaze-id уже потерян. Генерация при поиске
|
||||
сохраняет точную ссылку.
|
||||
|
||||
**Альтернатива (отклонена):** генерировать URL на лету в HTTP-обработчике
|
||||
из provider/provider_id. Не работает для TVMaze (provider в БД уже
|
||||
подменён на теговый). Хранить же оба id ради одной ссылки — дороже, чем
|
||||
одно поле URL.
|
||||
|
||||
### 2. URL — простая строка, без структуры (provider + path + id)
|
||||
|
||||
**Почему:** URL у каждого провайдера формируется по-разному (у TMDB зависит
|
||||
от mediaType, у TVDB — dereferrer, у IMDb — `/title/`). Хранить готовую
|
||||
строку проще, чем набор параметров + функцию сборки.
|
||||
|
||||
### 3. БД: новая миграция `0004_candidate_url.sql`
|
||||
|
||||
**Почему:** изменение схемы — стандартно через goose-миграцию. Миграция
|
||||
только добавляет nullable колонку (без DEFAULT, обратная совместимость).
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **URL может измениться** (провайдер меняет структуру сайта) → ссылка
|
||||
сломается. Вероятность низкая (TMDB/TVDB/IMDb не меняли схемы URL
|
||||
годами). Если сломается — фикс в одном месте (клиент провайдера),
|
||||
миграция не нужна.
|
||||
- **Для старых кандидатов url будет пустым** → в шаблоне показываем ссылку
|
||||
только если url не пустой; старые записи останутся без ссылки, новые
|
||||
получат при следующем распознавании.
|
||||
@@ -0,0 +1,24 @@
|
||||
## Why
|
||||
|
||||
При ревью человек видит кандидатов из внешних баз (TMDB/TVDB/TVMaze/IMDb), но не может быстро перейти на сайт базы и проверить, тот ли фильм/сериал был найден. Нужно добавить кликабельные ссылки на внешние сайты — чтобы за пару секунд убедиться в правильности матча, не копируя id вручную и не открывая поиск.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Генерация URL внешнего сайта для каждого кандидата (TMDB, TVDB, IMDb, TVMaze) на основе провайдера и id
|
||||
- Сохранение URL в `metadata_candidate` (новая колонка `url`)
|
||||
- Отображение ссылки в таблице кандидатов на странице ревью (веб-UI)
|
||||
- Ссылка открывается в новой вкладке (`target="_blank"`)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
<!-- None — изменение затрагивает только существующие потоки, новый capability не создаётся. -->
|
||||
|
||||
### Modified Capabilities
|
||||
- `recognition`: кандидаты внешних баз (`Candidate`) теперь несут URL для перехода на сайт-источник; URL сохраняется в БД и отображается в интерфейсе ревью
|
||||
|
||||
## Impact
|
||||
|
||||
- **БД**: миграция `0004_candidate_url.sql` — добавляет колонку `url TEXT` в `metadata_candidate`
|
||||
- **Код**: `metadata.Candidate` (+ поле `URL`), клиенты TMDB/TVDB/TVMaze (заполнение URL при Search), `store.MetadataCandidate` (+ поле `URL`), HTTP API `candidateView` (+ поле `URL`), шаблон `review.html` (колонка со ссылкой)
|
||||
- **API/транспорт**: внутреннее изменение, внешний API не затрагивается
|
||||
@@ -0,0 +1,50 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Кандидат несёт URL для внешней проверки
|
||||
|
||||
Каждый кандидат внешней базы метаданных (`metadata.Candidate`) SHALL нести
|
||||
поле `URL` — ссылку на страницу элемента (фильма/сериала) на сайте
|
||||
провайдера. URL SHALL формироваться клиентом провайдера при поиске
|
||||
(`Search`) и сохраняться в таблице `metadata_candidate`. На странице ревью
|
||||
URL SHALL отображаться кликабельной ссылкой, открывающейся в новой вкладке
|
||||
браузера.
|
||||
|
||||
Формат URL для каждого провайдера:
|
||||
|
||||
- **TMDB**: `https://www.themoviedb.org/movie/{id}` (фильм) или
|
||||
`https://www.themoviedb.org/tv/{id}` (сериал) — тип контента известен из
|
||||
запроса `Query.Type`
|
||||
- **TVDB**: `https://www.thetvdb.com/dereferrer/series/{id}`
|
||||
- **TVMaze**: `https://www.tvmaze.com/shows/{id}` — URL SHALL использовать
|
||||
нативный id TVMaze, а не внешний тег (TVDB/IMDb), чтобы ссылка вела на
|
||||
TVMaze-страницу
|
||||
|
||||
#### Scenario: Кандидат TMDB с корректной ссылкой
|
||||
|
||||
- **GIVEN** TMDB найден кандидат-фильм с id `603` («Матрица»)
|
||||
- **WHEN** клиент TMDB формирует Candidate
|
||||
- **THEN** `URL` = `https://www.themoviedb.org/movie/603`
|
||||
|
||||
#### Scenario: Кандидат TVMaze с нативной ссылкой
|
||||
|
||||
- **GIVEN** TVMaze найден сериал с id `169` («Фарго»), внешний тег — TVDB id `269613`
|
||||
- **WHEN** клиент TVMaze формирует Candidate
|
||||
- **THEN** `URL` = `https://www.tvmaze.com/shows/169`
|
||||
- **AND** `TagProvider`/`TagID` остаются `tvdb`/`269613` (тег папки Jellyfin не меняется)
|
||||
|
||||
#### Scenario: Ссылка в интерфейсе ревью
|
||||
|
||||
- **GIVEN** загрузка в состоянии `review` с кандидатами, у которых заполнен `url`
|
||||
- **WHEN** рендерится страница ревью
|
||||
- **THEN** в таблице кандидатов каждый кандидат SHALL отображаться со
|
||||
ссылкой на внешний сайт
|
||||
- **AND** ссылка открывается в новой вкладке (`target="_blank"`)
|
||||
- **AND** текстом ссылки служит провайдер или сокращённый url
|
||||
|
||||
#### Scenario: URL сохраняется в БД
|
||||
|
||||
- **GIVEN** результат поиска с кандидатами
|
||||
- **WHEN** кандидаты сохраняются в таблицу `metadata_candidate`
|
||||
- **THEN** значение `url` SHALL быть записано в колонку `url`
|
||||
- **AND** при последующей загрузке данных ревью url доступен без повторной
|
||||
генерации
|
||||
@@ -0,0 +1,30 @@
|
||||
## 1. Слой метаданных — генерация URL
|
||||
|
||||
- [x] 1.1 Добавить поле `URL` в структуру `metadata.Candidate` (`internal/metadata/metadata.go`)
|
||||
- [x] 1.2 Заполнять `URL` в `TMDB.Search`: `https://www.themoviedb.org/movie/{id}` (фильм) или `https://www.themoviedb.org/tv/{id}` (сериал)
|
||||
- [x] 1.3 Заполнять `URL` в `TVDB.Search`: `https://www.thetvdb.com/dereferrer/series/{id}`
|
||||
- [x] 1.4 Заполнять `URL` в `TVMaze.Search`: `https://www.tvmaze.com/shows/{id}` (нативный id, не теговый)
|
||||
- [x] 1.5 Обновить тесты клиентов метаданных (tmdb_test.go, tvdb_test.go, tvmaze_test.go) — проверить наличие URL в результатах Search
|
||||
|
||||
## 2. БД — хранение URL
|
||||
|
||||
- [x] 2.1 Создать миграцию `internal/store/migrations/0004_candidate_url.sql` — добавить колонку `url TEXT` в `metadata_candidate`
|
||||
- [x] 2.2 Добавить поле `URL` (`sql.NullString`) в структуру `store.MetadataCandidate`
|
||||
- [x] 2.3 Обновить `CreateCandidates` — сохранять `url` в INSERT
|
||||
- [x] 2.4 Обновить `docs/specs/database.md` — актуализировать ER-схему (колонка `url`)
|
||||
|
||||
## 3. Конвертация candidate → store
|
||||
|
||||
- [x] 3.1 В `worker.toStoreCandidates` пробрасывать `URL` из `metadata.Candidate` в `store.MetadataCandidate`
|
||||
|
||||
## 4. HTTP API и шаблон
|
||||
|
||||
- [x] 4.1 Добавить поле `URL` в `candidateView` (internal/httpapi/review.go)
|
||||
- [x] 4.2 Пробросить `URL` из `store.MetadataCandidate` в `candidateView` при сборе данных ревью
|
||||
- [x] 4.3 В шаблоне `web/templates/review.html` добавить колонку «ссылка» в таблицу кандидатов с `<a target="_blank">`
|
||||
|
||||
## 5. Проверка
|
||||
|
||||
- [x] 5.1 `task test` — все тесты проходят
|
||||
- [x] 5.2 `task lint` — без ошибок
|
||||
- [x] 5.3 `openspec validate --strict` — валидация спеки
|
||||
@@ -92,3 +92,53 @@ gracefully использует доступные названия.
|
||||
- **GIVEN** план с названием «Тёмный рыцарь» и кандидат базы «Темный рыцарь»
|
||||
- **WHEN** сравниваются нормализованные названия
|
||||
- **THEN** они считаются совпадающими
|
||||
|
||||
### Requirement: Кандидат несёт URL для внешней проверки
|
||||
|
||||
Каждый кандидат внешней базы метаданных (`metadata.Candidate`) SHALL нести
|
||||
поле `URL` — ссылку на страницу элемента (фильма/сериала) на сайте
|
||||
провайдера. URL SHALL формироваться клиентом провайдера при поиске
|
||||
(`Search`) и сохраняться в таблице `metadata_candidate`. На странице ревью
|
||||
URL SHALL отображаться кликабельной ссылкой, открывающейся в новой вкладке
|
||||
браузера.
|
||||
|
||||
Формат URL для каждого провайдера:
|
||||
|
||||
- **TMDB**: `https://www.themoviedb.org/movie/{id}` (фильм) или
|
||||
`https://www.themoviedb.org/tv/{id}` (сериал) — тип контента известен из
|
||||
запроса `Query.Type`
|
||||
- **TVDB**: `https://www.thetvdb.com/dereferrer/series/{id}`
|
||||
- **TVMaze**: `https://www.tvmaze.com/shows/{id}` — URL SHALL использовать
|
||||
нативный id TVMaze, а не внешний тег (TVDB/IMDb), чтобы ссылка вела на
|
||||
TVMaze-страницу
|
||||
|
||||
#### Scenario: Кандидат TMDB с корректной ссылкой
|
||||
|
||||
- **GIVEN** TMDB найден кандидат-фильм с id `603` («Матрица»)
|
||||
- **WHEN** клиент TMDB формирует Candidate
|
||||
- **THEN** `URL` = `https://www.themoviedb.org/movie/603`
|
||||
|
||||
#### Scenario: Кандидат TVMaze с нативной ссылкой
|
||||
|
||||
- **GIVEN** TVMaze найден сериал с id `169` («Фарго»), внешний тег — TVDB id `269613`
|
||||
- **WHEN** клиент TVMaze формирует Candidate
|
||||
- **THEN** `URL` = `https://www.tvmaze.com/shows/169`
|
||||
- **AND** `TagProvider`/`TagID` остаются `tvdb`/`269613` (тег папки Jellyfin не меняется)
|
||||
|
||||
#### Scenario: Ссылка в интерфейсе ревью
|
||||
|
||||
- **GIVEN** загрузка в состоянии `review` с кандидатами, у которых заполнен `url`
|
||||
- **WHEN** рендерится страница ревью
|
||||
- **THEN** в таблице кандидатов каждый кандидат SHALL отображаться со
|
||||
ссылкой на внешний сайт
|
||||
- **AND** ссылка открывается в новой вкладке (`target="_blank"`)
|
||||
- **AND** текстом ссылки служит провайдер или сокращённый url
|
||||
|
||||
#### Scenario: URL сохраняется в БД
|
||||
|
||||
- **GIVEN** результат поиска с кандидатами
|
||||
- **WHEN** кандидаты сохраняются в таблицу `metadata_candidate`
|
||||
- **THEN** значение `url` SHALL быть записано в колонку `url`
|
||||
- **AND** при последующей загрузке данных ревью url доступен без повторной
|
||||
генерации
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
|
||||
{{if .Candidates}}
|
||||
<table>
|
||||
<thead><tr><th>провайдер</th><th>название</th><th>год</th><th>id</th><th></th></tr></thead>
|
||||
<thead><tr><th>провайдер</th><th>название</th><th>год</th><th>id</th><th>ссылка</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Candidates}}
|
||||
<tr>
|
||||
@@ -83,6 +83,7 @@
|
||||
<td>{{.Title}}</td>
|
||||
<td>{{if .Year}}{{.Year}}{{end}}</td>
|
||||
<td class="src">{{.ProviderID}}</td>
|
||||
<td>{{if .URL}}<a href="{{.URL}}" target="_blank" rel="noopener">{{.Provider}}</a>{{end}}</td>
|
||||
<td>
|
||||
<form method="post" action="/ui/downloads/{{$.ID}}/candidate">
|
||||
<input type="hidden" name="candidate_id" value="{{.ID}}">
|
||||
|
||||
Reference in New Issue
Block a user