Ссылки на внешние базы в ревью (recognition)

Каждый кандидат внешней базы метаданных (TMDB/TVDB/TVMaze) теперь несёт
URL на страницу элемента — при ревью можно кликнуть и проверить матч.
URL формируется клиентом провайдера при поиске, сохраняется в БД
(metadata_candidate.url) и отображается ссылкой в веб-интерфейсе.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
av
2026-06-29 20:09:20 +03:00
co-authored by Claude
parent 6b7c090ce4
commit dfa182a5a9
20 changed files with 278 additions and 5 deletions
@@ -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` — валидация спеки