Ссылки на внешние базы в ревью (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
+2
View File
@@ -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,
})
}
+1
View File
@@ -36,6 +36,7 @@ type Candidate struct {
Title string
OriginalTitle string
Year int
URL string // ссылка на страницу элемента на сайте провайдера
TagProvider string // напр. "tvdb"/"imdb" (опц.)
TagID string
}
+5
View File
@@ -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
+6
View File
@@ -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) {
+1
View File
@@ -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
+3
View File
@@ -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)
+1
View File
@@ -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 {
+3
View File
@@ -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;
+4 -3
View File
@@ -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)
}
}
+3
View File
@@ -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
+27 -1
View File
@@ -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{