diff --git a/docs/specs/database.md b/docs/specs/database.md index a85567e..8c78576 100644 --- a/docs/specs/database.md +++ b/docs/specs/database.md @@ -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')" } diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index 61b49ba..2818cee 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -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, }) } diff --git a/internal/metadata/metadata.go b/internal/metadata/metadata.go index 5a47be4..087f603 100644 --- a/internal/metadata/metadata.go +++ b/internal/metadata/metadata.go @@ -36,6 +36,7 @@ type Candidate struct { Title string OriginalTitle string Year int + URL string // ссылка на страницу элемента на сайте провайдера TagProvider string // напр. "tvdb"/"imdb" (опц.) TagID string } diff --git a/internal/metadata/tmdb.go b/internal/metadata/tmdb.go index e6320fa..778d276 100644 --- a/internal/metadata/tmdb.go +++ b/internal/metadata/tmdb.go @@ -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 diff --git a/internal/metadata/tmdb_test.go b/internal/metadata/tmdb_test.go index 8800aea..ba028ee 100644 --- a/internal/metadata/tmdb_test.go +++ b/internal/metadata/tmdb_test.go @@ -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) { diff --git a/internal/metadata/tvdb.go b/internal/metadata/tvdb.go index 32204fb..2e47b88 100644 --- a/internal/metadata/tvdb.go +++ b/internal/metadata/tvdb.go @@ -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 diff --git a/internal/metadata/tvdb_test.go b/internal/metadata/tvdb_test.go index 95ae8c0..9dc3d8d 100644 --- a/internal/metadata/tvdb_test.go +++ b/internal/metadata/tvdb_test.go @@ -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) diff --git a/internal/metadata/tvmaze.go b/internal/metadata/tvmaze.go index f53b24c..48781e6 100644 --- a/internal/metadata/tvmaze.go +++ b/internal/metadata/tvmaze.go @@ -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 { diff --git a/internal/metadata/tvmaze_test.go b/internal/metadata/tvmaze_test.go index acd8525..6f8616a 100644 --- a/internal/metadata/tvmaze_test.go +++ b/internal/metadata/tvmaze_test.go @@ -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) diff --git a/internal/store/migrations/0004_candidate_url.sql b/internal/store/migrations/0004_candidate_url.sql new file mode 100644 index 0000000..11b3feb --- /dev/null +++ b/internal/store/migrations/0004_candidate_url.sql @@ -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; \ No newline at end of file diff --git a/internal/store/recognition.go b/internal/store/recognition.go index 303bf08..2b6d4b2 100644 --- a/internal/store/recognition.go +++ b/internal/store/recognition.go @@ -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) } } diff --git a/internal/worker/review.go b/internal/worker/review.go index 0380e57..0a45876 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -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 diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index aaed54b..16fcd46 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -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{ diff --git a/openspec/changes/archive/2026-06-29-review-external-links/.openspec.yaml b/openspec/changes/archive/2026-06-29-review-external-links/.openspec.yaml new file mode 100644 index 0000000..34f9314 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-review-external-links/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/archive/2026-06-29-review-external-links/design.md b/openspec/changes/archive/2026-06-29-review-external-links/design.md new file mode 100644 index 0000000..ad6aa43 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-review-external-links/design.md @@ -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 не пустой; старые записи останутся без ссылки, новые + получат при следующем распознавании. diff --git a/openspec/changes/archive/2026-06-29-review-external-links/proposal.md b/openspec/changes/archive/2026-06-29-review-external-links/proposal.md new file mode 100644 index 0000000..1d2997a --- /dev/null +++ b/openspec/changes/archive/2026-06-29-review-external-links/proposal.md @@ -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 + + +### 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 не затрагивается diff --git a/openspec/changes/archive/2026-06-29-review-external-links/specs/recognition/spec.md b/openspec/changes/archive/2026-06-29-review-external-links/specs/recognition/spec.md new file mode 100644 index 0000000..8bee996 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-review-external-links/specs/recognition/spec.md @@ -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 доступен без повторной + генерации diff --git a/openspec/changes/archive/2026-06-29-review-external-links/tasks.md b/openspec/changes/archive/2026-06-29-review-external-links/tasks.md new file mode 100644 index 0000000..15614ea --- /dev/null +++ b/openspec/changes/archive/2026-06-29-review-external-links/tasks.md @@ -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` добавить колонку «ссылка» в таблицу кандидатов с `` + +## 5. Проверка + +- [x] 5.1 `task test` — все тесты проходят +- [x] 5.2 `task lint` — без ошибок +- [x] 5.3 `openspec validate --strict` — валидация спеки diff --git a/openspec/specs/recognition/spec.md b/openspec/specs/recognition/spec.md index 8f454cd..c550df9 100644 --- a/openspec/specs/recognition/spec.md +++ b/openspec/specs/recognition/spec.md @@ -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 доступен без повторной + генерации + diff --git a/web/templates/review.html b/web/templates/review.html index 93a55a5..c41ee6e 100644 --- a/web/templates/review.html +++ b/web/templates/review.html @@ -75,7 +75,7 @@ {{if .Candidates}} - + {{range .Candidates}} @@ -83,6 +83,7 @@ +
провайдерназваниегодid
провайдерназваниегодidссылка
{{.Title}} {{if .Year}}{{.Year}}{{end}} {{.ProviderID}}{{if .URL}}{{.Provider}}{{end}}