diff --git a/docs/specs/review-ux.md b/docs/specs/review-ux.md index 6469b60..96a1c85 100644 --- a/docs/specs/review-ux.md +++ b/docs/specs/review-ux.md @@ -49,7 +49,14 @@ Fargo.S02.2015.WEB-DL.1080p.rus.eng 🟑 review ΠšΠΎΠ½Ρ‚Π΅ΠΊΡΡ‚: Β«Π²Ρ‚ΠΎΡ€ΠΎΠΉ сСзон, рус+Π°Π½Π³Π» Π΄ΠΎΡ€ΠΎΠΆΠΊΠΈΒ» [+ Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ β†’ πŸ” ΠΏΠ΅Ρ€Π΅Ρ€Π°ΡΠΏΠΎΠ·Π½Π°Ρ‚ΡŒ] Π’ΠΈΠΏ: ( ) Ρ„ΠΈΠ»ΡŒΠΌ (β€’) сСриал НазваниС: Π€Π°Ρ€Π³ΠΎ Π“ΠΎΠ΄: 2015 Π‘Π΅Π·ΠΎΠ½: 02 -Π‘Π°Π·Π°: [TMDB поиск…] [TVDB поиск…] Π²Ρ‹Π±Ρ€Π°Π½ΠΎ: β€” (Π±Π΅Π· Π±Π°Π·Ρ‹) [ввСсти id] + +Π˜ΡΡ‚ΠΎΡ‡Π½ΠΈΠΊ совпадСния (Π΅Π΄ΠΈΠ½Ρ‹ΠΉ список β€” Π²Ρ‹Π±ΠΈΡ€Π°Π΅ΠΌ источник, Π° Π½Π΅ Ρ€Π΅ΠΆΠΈΠΌ): + (β€’) распознано Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠΎΠΉ (Π±Π΅Π· Π±Π°Π·Ρ‹) [Π°ΠΊΡ‚ΠΈΠ²Π΅Π½] + ( ) tvdb Fargo Β· 2014 id 269613 [Π·Π°ΠΏΠΈΡΡŒβ†—] [прСдпросмотр▸] [Π²Ρ‹Π±Ρ€Π°Ρ‚ΡŒ] + ( ) tmdb Fargo id 60622 [Π·Π°ΠΏΠΈΡΡŒβ†—] [прСдпросмотр▸] [Π²Ρ‹Π±Ρ€Π°Ρ‚ΡŒ] + + Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π²Ρ€ΡƒΡ‡Π½ΡƒΡŽ: [tmdbβ–Ύ] [id ΠΈΠ»ΠΈ URL записи] [Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ] + прСдпросмотр▸ раскрываСт поля (Ρ‚ΠΈΠΏ/Π½Π°Π·Π²Π°Π½ΠΈΠ΅/Π³ΠΎΠ΄, мСсто ΠΏΠΎΠ΄ рСТиссёра) ΠΈ + Ρ†Π΅Π»Π΅Π²Ρ‹Π΅ ΠΏΡƒΡ‚ΠΈ Π­Π’ΠžΠ“Πž источника β€” Π΄ΠΎ Π²Ρ‹Π±ΠΎΡ€Π°, Π½ΠΈΡ‡Π΅Π³ΠΎ Π½Π΅ мСняя Π€Π°ΠΉΠ»Ρ‹ β†’ сСрии: # | Ρ„Π°ΠΉΠ» | Ρ€Π°Π·ΠΌΠ΅Ρ€ | Ρ€ΠΎΠ»ΡŒ | S | E @@ -141,9 +148,13 @@ Telegram = ΠΎΠ΄ΠΎΠ±Ρ€ΠΈΡ‚ΡŒ / ΠΏΠΎΠ΄ΡΠΊΠ°Π·Π°Ρ‚ΡŒ / Π²Ρ‹Π±Ρ€Π°Ρ‚ΡŒ ΠΊΠ°Π½Π΄ΠΈΠ΄ ## ΠžΠ±ΡŠΡ‘ΠΌ ΠΏΠΎ вСрсиям - **Π€3 (Π³ΠΎΡ‚ΠΎΠ²ΠΎ):** Π² Π²Π΅Π±Π΅ β€” подсказка + пСрСраспознаваниС, Β«Π Π°ΡΠΏΠΎΠ·Π½Π°Ρ‚ΡŒ - Π·Π°Π½ΠΎΠ²ΠΎΒ», ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π°Ρ‚Π΅Π»ΡŒ Ρ‚ΠΈΠΏΠ°, Π²Ρ‹Π±ΠΎΡ€ ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π° Π±Π°Π·Ρ‹ / Ρ€ΡƒΡ‡Π½ΠΎΠΉ Π²Π²ΠΎΠ΄ id / - Β«Π±Π΅Π· Π±Π°Π·Ρ‹Β», ΠΏΠΎΠΌΠ΅Ρ‚ΠΊΠ° Ρ„Π°ΠΉΠ»Π° Β«ΠΈΠ³Π½ΠΎΡ€Β», Β«ΠŸΡ€ΠΈΠΌΠ΅Π½ΠΈΡ‚ΡŒΒ»/Β«ΠžΡ‚ΠΊΠ»ΠΎΠ½ΠΈΡ‚ΡŒΒ»/«ПозТС», - Undo ΠΈ Β«ΠŸΡ€ΠΈΠ²ΡΠ·Π°Ρ‚ΡŒ Π·Π°Π½ΠΎΠ²ΠΎΒ». Π’ Telegram β€” ΠΏΠΎΠ΄Ρ‚Π²Π΅Ρ€ΠΆΠ΄Π΅Π½ΠΈΠ΅ с reply-подсказкой + Π·Π°Π½ΠΎΠ²ΠΎΒ», ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π°Ρ‚Π΅Π»ΡŒ Ρ‚ΠΈΠΏΠ°, **Π΅Π΄ΠΈΠ½Ρ‹ΠΉ список источников совпадСния** + (Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠ° Π½Π°Ρ€Π°Π²Π½Π΅ с ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π°ΠΌΠΈ Π±Π°Π·; Π²Ρ‹Π±ΠΎΡ€/ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΠ΅/снятиС Π² ΠΏΠΎΠ»ΡŒΠ·Ρƒ + Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠΈ), **Ρ€ΡƒΡ‡Π½ΠΎΠ΅ Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ΠΈΠ΅ источника ΠΏΠΎ id ΠΈΠ»ΠΈ URL** (TMDB/IMDb β€” ΠΏΠΎ + URL, TVDB β€” ΠΏΠΎ числовому id), **прСдпросмотр ΠΏΠΎΠ»Π΅ΠΉ ΠΈ Ρ†Π΅Π»Π΅Π²Ρ‹Ρ… ΠΏΡƒΡ‚Π΅ΠΉ ΠΊΠ°ΠΆΠ΄ΠΎΠ³ΠΎ + источника Π΄ΠΎ примСнСния** (мСсто ΠΏΠΎΠ΄ рСТиссёра Π·Π°Ρ€Π΅Π·Π΅Ρ€Π²ΠΈΡ€ΠΎΠ²Π°Π½ΠΎ), ΠΏΠΎΠΌΠ΅Ρ‚ΠΊΠ° + Ρ„Π°ΠΉΠ»Π° Β«ΠΈΠ³Π½ΠΎΡ€Β», Β«ΠŸΡ€ΠΈΠΌΠ΅Π½ΠΈΡ‚ΡŒΒ»/Β«ΠžΡ‚ΠΊΠ»ΠΎΠ½ΠΈΡ‚ΡŒΒ»/«ПозТС», Undo ΠΈ Β«ΠŸΡ€ΠΈΠ²ΡΠ·Π°Ρ‚ΡŒ Π·Π°Π½ΠΎΠ²ΠΎΒ». + Π’ Telegram β€” ΠΏΠΎΠ΄Ρ‚Π²Π΅Ρ€ΠΆΠ΄Π΅Π½ΠΈΠ΅ с reply-подсказкой (Β«Π£Ρ‚ΠΎΡ‡Π½ΠΈΡ‚ΡŒΒ»), ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π°Ρ‚Π΅Π»ΡŒ Ρ‚ΠΈΠΏΠ°, «ПозТС»/Β«ΠžΡ‚ΠΊΠ»ΠΎΠ½ΠΈΡ‚ΡŒΒ» ΠΈ эскалация Π² Π²Π΅Π±; ΠΏΠΈΠ½Π³ΠΈ ΠΎ Π²Ρ…ΠΎΠ΄Π΅ Π² review ΠΈ готовности. - **Π€5 (Π½Π° Π±ΡƒΠ΄ΡƒΡ‰Π΅Π΅):** ΠΏΠΎΠ»Π½Ρ‹ΠΉ Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€ ΠΌΠ°ΠΏΠΏΠΈΠ½Π³Π° Β«Ρ„Π°ΠΉΠ» β†’ сСрия» diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 5316198..e87d1da 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -118,6 +118,7 @@ func NewRouter(d Deps) (http.Handler, error) { r.Post("/ui/downloads/{id}/ignore", s.handleIgnore) r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate) r.Post("/ui/downloads/{id}/provider", s.handleSetProvider) + r.Post("/ui/downloads/{id}/source", s.handleAddSource) r.Post("/ui/downloads/{id}/nobase", s.handleNoBase) r.Post("/ui/downloads/{id}/defer", s.handleDefer) r.Post("/ui/downloads/{id}/undo", s.handleUndo) @@ -631,6 +632,11 @@ func classifyErr(err error) (int, string) { case errors.Is(err, worker.ErrConflict): // ΠΠΎΡ€ΠΌΠ°Π»ΡŒΠ½Ρ‹ΠΉ ΠΊΠΎΠ½Ρ„Π»ΠΈΠΊΡ‚ состояния (опСрация нСдопустима сСйчас), Π½Π΅ сбой. return http.StatusConflict, "дСйствиС нСдоступно Π² Ρ‚Π΅ΠΊΡƒΡ‰Π΅ΠΌ состоянии" + case errors.Is(err, errManualSource): + // Ошибка Ρ€ΡƒΡ‡Π½ΠΎΠ³ΠΎ Π²Π²ΠΎΠ΄Π° источника β€” ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅ΠΌ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŽ ΠΊΠ°ΠΊ Π΅ΡΡ‚ΡŒ. + return http.StatusBadRequest, errManualSource.Error() + case errors.Is(err, errInvalidCandidate): + return http.StatusBadRequest, errInvalidCandidate.Error() default: return http.StatusInternalServerError, "внутрСнняя ошибка" } diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 9188311..574d246 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -399,6 +399,7 @@ type fakeReviewer struct { ignored map[string]string chosen map[string]string providerSet map[string]string + sourceAdded map[string]string applied []string deferred []string undone []string @@ -468,6 +469,13 @@ func (f *fakeReviewer) SetProviderID(_ context.Context, id string, provider, pro f.providerSet[id] = provider + ":" + providerID return nil } +func (f *fakeReviewer) AddManualSource(_ context.Context, id, provider, providerID string) error { + if f.sourceAdded == nil { + f.sourceAdded = map[string]string{} + } + f.sourceAdded[id] = provider + ":" + providerID + return nil +} func (f *fakeReviewer) ClearProvider(_ context.Context, id string) error { f.cleared = append(f.cleared, id) return nil @@ -475,25 +483,37 @@ func (f *fakeReviewer) ClearProvider(_ context.Context, id string) error { func seriesReviewData() *worker.ReviewData { s, e := 2, 1 + plan := recognize.Plan{ + Type: recognize.MediaSeries, Title: "Π€Π°Ρ€Π³ΠΎ", Year: 2015, + Files: []recognize.PlanFile{ + {Src: "Fargo/e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e}, + }, + } + preview := []layout.Link{ + {Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Π€Π°Ρ€Π³ΠΎ (2015)/Season 02/Π€Π°Ρ€Π³ΠΎ (2015) S02E01.mkv"}, + } return &worker.ReviewData{ Download: store.Download{ID: tid, State: store.StateReview, SourceRef: "magnet:?xt=urn:btih:abc"}, Recognition: &store.Recognition{ ID: "1", DownloadID: tid, IsCurrent: true, Reasons: `["Π½Π΅Ρ‚ ΠΌΠ°Ρ‚Ρ‡Π° Π² Π±Π°Π·Π΅"]`, }, - Plan: recognize.Plan{ - Type: recognize.MediaSeries, Title: "Π€Π°Ρ€Π³ΠΎ", Year: 2015, - Files: []recognize.PlanFile{ - {Src: "Fargo/e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e}, - }, - }, - Preview: []layout.Link{ - {Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Π€Π°Ρ€Π³ΠΎ (2015)/Season 02/Π€Π°Ρ€Π³ΠΎ (2015) S02E01.mkv"}, - }, + Plan: plan, + Preview: preview, Candidates: []store.MetadataCandidate{ {ID: cid, Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"), Year: sql.NullInt64{Int64: 2014, Valid: true}}, {ID: cid2, Provider: "tmdb", ProviderID: "60622", Title: store.NullString("Fargo")}, }, + // Π•Π΄ΠΈΠ½Ρ‹ΠΉ список источников: Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠ° Π°ΠΊΡ‚ΠΈΠ²Π½Π° (ΠΌΠ°Ρ‚Ρ‡Π° Π½Π΅Ρ‚) + ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Ρ‹. + Sources: []worker.SourceOption{ + {Kind: worker.SourceNeural, Provider: "none", Title: "Π€Π°Ρ€Π³ΠΎ", Year: 2015, + Type: "series", Active: true, Plan: plan, Preview: preview}, + {Kind: worker.SourceCandidate, CandidateID: cid, Provider: "tvdb", ProviderID: "269613", + URL: "https://www.thetvdb.com/dereferrer/series/269613", Title: "Fargo", Year: 2014, + Type: "series", Plan: plan, Preview: preview}, + {Kind: worker.SourceCandidate, CandidateID: cid2, Provider: "tmdb", ProviderID: "60622", + Title: "Fargo", Type: "series", Plan: plan, Preview: preview}, + }, Hints: []string{"Π²Ρ‚ΠΎΡ€ΠΎΠΉ сСзон"}, } } @@ -521,7 +541,7 @@ func TestReviewRenders(t *testing.T) { } for _, want := range []string{"Π€Π°Ρ€Π³ΠΎ", "Π½Π΅Ρ‚ ΠΌΠ°Ρ‚Ρ‡Π° Π² Π±Π°Π·Π΅", "Fargo/e1.mkv", "Season 02", "ΠŸΡ€ΠΈΠΌΠ΅Π½ΠΈΡ‚ΡŒ", "Π£Ρ‚ΠΎΡ‡Π½ΠΈΡ‚ΡŒ", - "Π˜ΡΡ‚ΠΎΡ‡Π½ΠΈΠΊ совпадСния", "269613", "Π²Ρ‹Π±Ρ€Π°Ρ‚ΡŒ", "Π‘Π΅Π· Π±Π°Π·Ρ‹"} { + "Π˜ΡΡ‚ΠΎΡ‡Π½ΠΈΠΊ совпадСния", "269613", "Π²Ρ‹Π±Ρ€Π°Ρ‚ΡŒ", "распознано Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠΎΠΉ", "Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ"} { if !strings.Contains(string(body), want) { t.Errorf("страница Ρ€Π΅Π²ΡŒΡŽ Π½Π΅ содСрТит %q", want) } @@ -529,12 +549,13 @@ func TestReviewRenders(t *testing.T) { } func TestReviewShowsMatchLink(t *testing.T) { - // ΠŸΠΎΠ΄Ρ‚Π²Π΅Ρ€ΠΆΠ΄Ρ‘Π½Π½Ρ‹ΠΉ ΠΌΠ°Ρ‚Ρ‡ Π² Π±Π»ΠΎΠΊΠ΅ Β«Π˜ΡΡ‚ΠΎΡ‡Π½ΠΈΠΊ совпадСния» β€” ссылкой Π½Π° запись. + // ΠŸΠΎΠ΄Ρ‚Π²Π΅Ρ€ΠΆΠ΄Ρ‘Π½Π½Ρ‹ΠΉ ΠΌΠ°Ρ‚Ρ‡ Π² спискС источников β€” ссылкой Π½Π° запись. rd := seriesReviewData() rd.Provider = "tvdb" rd.ProviderID = "269613" - rd.Candidates[0].Chosen = true // tvdb 269613 Π² seriesReviewData - rd.Candidates[0].URL = store.NullString("https://www.thetvdb.com/dereferrer/series/269613") + // АктивСн ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚ tvdb (индСкс 1: Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠ°, tvdb, tmdb). + rd.Sources[0].Active = false + rd.Sources[1].Active = true rv := &fakeReviewer{data: rd} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: &fakeReader{}, Reviewer: rv}) @@ -591,6 +612,47 @@ func TestSetProviderAndNoBase(t *testing.T) { } } +func TestAddManualSource(t *testing.T) { + rv := &fakeReviewer{data: seriesReviewData()} + srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, + Reader: &fakeReader{}, Reviewer: rv}) + cl := noRedirectClient() + + // URL записи β†’ ΠΏΡ€ΠΎΠ²Π°ΠΉΠ΄Π΅Ρ€+id Ρ€Π°Π·Π±ΠΈΡ€Π°ΡŽΡ‚ΡΡ Π½Π° Π³Ρ€Π°Π½ΠΈΡ†Π΅, Π²ΠΎΡ€ΠΊΠ΅Ρ€ ΠΏΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚ ΠΈΡ…. + if _, err := cl.PostForm(srv.URL+"/ui/downloads/"+tid+"/source", + map[string][]string{"provider": {"tmdb"}, "provider_id": {"https://www.themoviedb.org/tv/60622-fargo"}}); err != nil { + t.Fatal(err) + } + if rv.sourceAdded[tid] != "tmdb:60622" { + t.Errorf("AddManualSource ΠΏΠΎΠ»ΡƒΡ‡ΠΈΠ» %q, want tmdb:60622", rv.sourceAdded[tid]) + } +} + +func TestAddManualSource_RejectsBadURL(t *testing.T) { + rv := &fakeReviewer{data: seriesReviewData()} + srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, + Reader: &fakeReader{}, Reviewer: rv}) + + // URL TVDB со slug (Π±Π΅Π· числового id) Π½Π΅ разбираСтся β†’ ΠΎΡ‚ΠΊΠ»ΠΎΠ½Ρ‘Π½, источник + // Π½Π΅ Π΄ΠΎΠ±Π°Π²Π»Π΅Π½, ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŽ ΡƒΡ…ΠΎΠ΄ΠΈΡ‚ сообщСниС (Π½Π΅ «внутрСнняя ошибка»). + resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/"+tid+"/source", + map[string][]string{"provider": {"tvdb"}, "provider_id": {"https://www.thetvdb.com/series/fargo"}}) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if _, called := rv.sourceAdded[tid]; called { + t.Errorf("Π½Π΅Π²Π°Π»ΠΈΠ΄Π½Ρ‹ΠΉ Π²Π²ΠΎΠ΄ Π½Π΅ Π΄ΠΎΠ»ΠΆΠ΅Π½ Π²Ρ‹Π·Ρ‹Π²Π°Ρ‚ΡŒ AddManualSource: %v", rv.sourceAdded) + } + loc := resp.Header.Get("Location") + if !strings.HasPrefix(loc, "/review/"+tid) || !strings.Contains(loc, "err=") { + t.Errorf("Location = %q, want Ρ€Π΅Π΄ΠΈΡ€Π΅ΠΊΡ‚ Π½Π° Ρ€Π΅Π²ΡŒΡŽ с ?err=", loc) + } + if strings.Contains(loc, "%D0%B2%D0%BD%D1%83%D1%82%D1%80%D0%B5%D0%BD") { // «внутрСн…» + t.Errorf("ошибка Π²Π²ΠΎΠ΄Π° ΠΏΠΎΠ΄Π°Π½Π° ΠΊΠ°ΠΊ внутрСнняя: %q", loc) + } +} + func TestApplyRedirectsToIndex(t *testing.T) { rv := &fakeReviewer{data: seriesReviewData()} srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, diff --git a/internal/httpapi/providerurl_test.go b/internal/httpapi/providerurl_test.go index 136a519..d15967f 100644 --- a/internal/httpapi/providerurl_test.go +++ b/internal/httpapi/providerurl_test.go @@ -32,6 +32,45 @@ func TestProviderURL(t *testing.T) { } } +func TestParseManualSource(t *testing.T) { + cases := []struct { + name string + provider string + raw string + wantProvider string + wantID string + wantErr bool + }{ + {"id с ΠΏΡ€ΠΎΠ²Π°ΠΉΠ΄Π΅Ρ€ΠΎΠΌ", "tmdb", "60622", "tmdb", "60622", false}, + {"id обрСзаСтся ΠΏΠΎΠ·ΠΆΠ΅ Π²ΠΎΡ€ΠΊΠ΅Ρ€ΠΎΠΌ", "TVDB", "269613", "tvdb", "269613", false}, + {"URL TMDB со slug", "", "https://www.themoviedb.org/tv/60622-fargo", "tmdb", "60622", false}, + {"URL TMDB Π±Π΅Π· схСмы", "", "themoviedb.org/tv/60622", "tmdb", "60622", false}, + {"URL TMDB movie", "", "https://www.themoviedb.org/movie/693134", "tmdb", "693134", false}, + {"URL IMDb", "", "https://www.imdb.com/title/tt0111161/", "imdb", "tt0111161", false}, + {"URL TVDB dereferrer", "", "https://www.thetvdb.com/dereferrer/series/269613", "tvdb", "269613", false}, + {"URL TVDB slug β€” Π±Π΅Π· id β†’ ошибка", "", "https://www.thetvdb.com/series/fargo", "", "", true}, + {"мусорный URL β†’ ошибка", "", "https://example.com/foo/bar", "", "", true}, + {"пустой Π²Π²ΠΎΠ΄ β†’ ошибка", "tmdb", " ", "", "", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + p, id, err := parseManualSource(c.provider, c.raw) + if c.wantErr { + if err == nil { + t.Fatalf("оТидалась ошибка, ΠΏΠΎΠ»ΡƒΡ‡Π΅Π½ΠΎ %q:%q", p, id) + } + return + } + if err != nil { + t.Fatalf("нСоТиданная ошибка: %v", err) + } + if p != c.wantProvider || id != c.wantID { + t.Errorf("parseManualSource(%q,%q) = %q:%q, want %q:%q", c.provider, c.raw, p, id, c.wantProvider, c.wantID) + } + }) + } +} + // TestMatchURLNoLinkWhenUnbuildable β€” эффСктивный ΠΏΡ€ΠΎΠ²Π°ΠΉΠ΄Π΅Ρ€, для ΠΊΠΎΡ‚ΠΎΡ€ΠΎΠ³ΠΎ URL Π½Π΅ // строится ΠΈ ΡΠΎΠ²ΠΏΠ°Π΄Π°ΡŽΡ‰Π΅Π³ΠΎ ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π° Π½Π΅Ρ‚, Π΄Π°Ρ‘Ρ‚ ΠΏΡƒΡΡ‚ΡƒΡŽ ссылку (транспорт ΠΏΠΎΠΊΠ°ΠΆΠ΅Ρ‚ // ΠΌΠ°Ρ‚Ρ‡ тСкстом β€” сцСнарий Β«URL записи нСизвСстСн»). diff --git a/internal/httpapi/render_test.go b/internal/httpapi/render_test.go index ae9eb03..a0fafc9 100644 --- a/internal/httpapi/render_test.go +++ b/internal/httpapi/render_test.go @@ -49,7 +49,10 @@ func (stubReviewer) Relink(context.Context, string) error func (stubReviewer) Rerecognize(context.Context, string) error { return nil } func (stubReviewer) ChooseCandidate(context.Context, string, string) error { return nil } func (stubReviewer) SetProviderID(context.Context, string, string, string) error { return nil } -func (stubReviewer) ClearProvider(context.Context, string) error { return nil } +func (stubReviewer) AddManualSource(context.Context, string, string, string) error { + return nil +} +func (stubReviewer) ClearProvider(context.Context, string) error { return nil } // stubLive β€” Π·Π°Π³Π»ΡƒΡˆΠΊΠ° источника ΠΆΠΈΠ²ΠΎΠΉ Ρ‚Π΅Π»Π΅ΠΌΠ΅Ρ‚Ρ€ΠΈΠΈ. type stubLive struct{ m map[string]worker.Live } diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go index b994bbd..eddb32b 100644 --- a/internal/httpapi/review.go +++ b/internal/httpapi/review.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "git.vakhrushev.me/av/jellybit/internal/ident" "git.vakhrushev.me/av/jellybit/internal/store" @@ -25,6 +26,7 @@ type Reviewer interface { Rerecognize(ctx context.Context, id string) error ChooseCandidate(ctx context.Context, id, candidateID string) error SetProviderID(ctx context.Context, id string, provider, providerID string) error + AddManualSource(ctx context.Context, id, provider, providerID string) error ClearProvider(ctx context.Context, id string) error } @@ -50,19 +52,24 @@ type reviewView struct { Hints []string Files []fileRow HasPlan bool - HasLinks bool // Π΅ΡΡ‚ΡŒ хотя Π±Ρ‹ ΠΎΠ΄ΠΈΠ½ Ρ†Π΅Π»Π΅Π²ΠΎΠΉ ΠΏΡƒΡ‚ΡŒ β†’ ΠΌΠΎΠΆΠ½ΠΎ ΠΏΡ€ΠΈΠΌΠ΅Π½ΡΡ‚ΡŒ - NoBase bool // Π²Ρ‹Π±Ρ€Π°Π½ΠΎ Β«Π±Π΅Π· Π±Π°Π·Ρ‹Β» - Candidates []candidateView + HasLinks bool // Π΅ΡΡ‚ΡŒ хотя Π±Ρ‹ ΠΎΠ΄ΠΈΠ½ Ρ†Π΅Π»Π΅Π²ΠΎΠΉ ΠΏΡƒΡ‚ΡŒ β†’ ΠΌΠΎΠΆΠ½ΠΎ ΠΏΡ€ΠΈΠΌΠ΅Π½ΡΡ‚ΡŒ + NoBase bool // Π²Ρ‹Π±Ρ€Π°Π½ΠΎ Β«Π±Π΅Π· Π±Π°Π·Ρ‹Β» + Sources []sourceView // Π΅Π΄ΠΈΠ½Ρ‹ΠΉ список источников совпадСния } -type candidateView struct { - ID string - Provider string - ProviderID string - Title string - Year int - URL string - Chosen bool +// sourceView β€” строка Π΅Π΄ΠΈΠ½ΠΎΠ³ΠΎ списка источников Π½Π° экранС Ρ€Π΅Π²ΡŒΡŽ: Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠ° ΠΈΠ»ΠΈ +// ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚ Π±Π°Π·Ρ‹, с эффСктивными полями ΠΈ прСдпросмотром Ρ†Π΅Π»Π΅Π²Ρ‹Ρ… ΠΏΡƒΡ‚Π΅ΠΉ. +type sourceView struct { + Kind string // "neural" | "candidate" + CandidateID string + Provider string + ProviderID string + Title string + Year int + IsSeries bool + MatchURL string + Active bool + Files []fileRow // прСдпросмотр Β«Ρ„Π°ΠΉΠ» β†’ раскладка» этого источника } func (s *server) handleReview(w http.ResponseWriter, r *http.Request) { @@ -113,16 +120,22 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) { view.Files = buildFileRows(rd.Plan, rd.Preview) view.HasPlan = len(rd.Plan.Files) > 0 view.HasLinks = len(rd.Preview) > 0 - for _, c := range rd.Candidates { - view.Candidates = append(view.Candidates, candidateView{ - ID: c.ID, - Provider: c.Provider, - ProviderID: c.ProviderID, - Title: c.Title.String, - Year: int(c.Year.Int64), - URL: c.URL.String, - Chosen: c.Chosen, - }) + for _, src := range rd.Sources { + sv := sourceView{ + Kind: string(src.Kind), + CandidateID: src.CandidateID, + Provider: src.Provider, + ProviderID: src.ProviderID, + Title: src.Title, + Year: src.Year, + IsSeries: src.Type == "series", + Active: src.Active, + Files: buildFileRows(src.Plan, src.Preview), + } + if src.Kind == worker.SourceCandidate { + sv.MatchURL = sourceMatchURL(src) + } + view.Sources = append(view.Sources, sv) } } @@ -198,8 +211,101 @@ func (s *server) handleNoBase(w http.ResponseWriter, r *http.Request) { }) } +// handleAddSource добавляСт источник Π²Ρ€ΡƒΡ‡Π½ΡƒΡŽ ΠΏΠΎ id ΠΈΠ»ΠΈ URL записи ΠΌΠ΅Ρ‚Π°Π±Π°Π·Ρ‹ ΠΈ +// Π²Ρ‹Π±ΠΈΡ€Π°Π΅Ρ‚ Π΅Π³ΠΎ. Π Π°Π·Π±ΠΎΡ€ Π²Π²ΠΎΠ΄Π° β€” Π½Π° Π²Ρ…ΠΎΠ΄Π½ΠΎΠΉ Π³Ρ€Π°Π½ΠΈΡ†Π΅ транспорта. +func (s *server) handleAddSource(w http.ResponseWriter, r *http.Request) { + s.reviewAction(w, r, func(ctx context.Context, id string) error { + _ = r.ParseForm() + provider, providerID, err := parseManualSource(r.PostForm.Get("provider"), r.PostForm.Get("provider_id")) + if err != nil { + return err + } + return s.deps.Reviewer.AddManualSource(ctx, id, provider, providerID) + }) +} + var errInvalidCandidate = errors.New("Π½Π΅ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½Ρ‹ΠΉ id ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π°") +var errManualSource = errors.New("Π½Π΅ ΡƒΠ΄Π°Π»ΠΎΡΡŒ Ρ€Π°Π·ΠΎΠ±Ρ€Π°Ρ‚ΡŒ id ΠΈΠ»ΠΈ URL записи (для TVDB β€” числовой id)") + +// parseManualSource Ρ€Π°Π·Π±ΠΈΡ€Π°Π΅Ρ‚ Ρ€ΡƒΡ‡Π½ΠΎΠΉ Π²Π²ΠΎΠ΄ источника: Π»ΠΈΠ±ΠΎ id (с Π²Ρ‹Π±Ρ€Π°Π½Π½Ρ‹ΠΌ Π² +// Ρ„ΠΎΡ€ΠΌΠ΅ ΠΏΡ€ΠΎΠ²Π°ΠΉΠ΄Π΅Ρ€ΠΎΠΌ), Π»ΠΈΠ±ΠΎ URL записи ΠΌΠ΅Ρ‚Π°Π±Π°Π·Ρ‹ (ΠΏΡ€ΠΎΠ²Π°ΠΉΠ΄Π΅Ρ€ ΠΈ id ΠΈΠ· URL). +func parseManualSource(provider, raw string) (string, string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", errManualSource + } + if looksLikeURL(raw) { + // URL Π±Π΅Π· схСмы (`themoviedb.org/tv/1`) url.Parse ΠΊΠ»Π°Π΄Ρ‘Ρ‚ Π² Path, Π° Π½Π΅ Π² + // Host β†’ достраиваСм схСму, ΠΈΠ½Π°Ρ‡Π΅ Π²Π°Π»ΠΈΠ΄Π½Ρ‹ΠΉ копипаст отвСргнСтся. + u := raw + if !strings.Contains(u, "://") { + u = "https://" + u + } + p, id, ok := parseProviderURL(u) + if !ok { + return "", "", errManualSource + } + return p, id, nil + } + return strings.ToLower(strings.TrimSpace(provider)), raw, nil +} + +func looksLikeURL(s string) bool { + return strings.Contains(s, "://") || strings.HasPrefix(s, "www.") || + strings.Contains(s, ".org/") || strings.Contains(s, ".com/") +} + +// parseProviderURL β€” обратная ΠΊ providerURL: URL записи β†’ (provider, id). +// TMDB/IMDb ΠΈΠ·Π²Π»Π΅ΠΊΠ°ΡŽΡ‚ΡΡ ΠΈΠ· URL; TVDB β€” Ρ‚ΠΎΠ»ΡŒΠΊΠΎ dereferrer с числовым id (URL +// сайта thetvdb.com/series/{slug} числового id Π½Π΅ содСрТит β†’ Π½Π΅ распознаём). +func parseProviderURL(raw string) (provider, id string, ok bool) { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return "", "", false + } + host := strings.ToLower(u.Host) + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + switch { + case strings.Contains(host, "themoviedb.org"): + if len(parts) >= 2 && (parts[0] == "movie" || parts[0] == "tv") { + if d := leadingDigits(parts[1]); d != "" { + return "tmdb", d, true + } + } + case strings.Contains(host, "imdb.com"): + if len(parts) >= 2 && parts[0] == "title" && strings.HasPrefix(parts[1], "tt") { + return "imdb", parts[1], true + } + case strings.Contains(host, "thetvdb.com"): + if len(parts) >= 3 && parts[0] == "dereferrer" { + if d := leadingDigits(parts[2]); d != "" { + return "tvdb", d, true + } + } + } + return "", "", false +} + +// leadingDigits Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ Π²Π΅Π΄ΡƒΡ‰ΠΈΠ΅ Ρ†ΠΈΡ„Ρ€Ρ‹ строки (TMDB-URL Π²ΠΈΠ΄Π° +// `/movie/60622-fargo` β†’ Β«60622Β»). +func leadingDigits(s string) string { + i := 0 + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + return s[:i] +} + +// sourceMatchURL β€” ссылка Π½Π° запись источника-ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π°: URL ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π°, Ссли +// Π΅ΡΡ‚ΡŒ, ΠΈΠ½Π°Ρ‡Π΅ каноничСский URL ΠΈΠ· provider/id (ΠΎΠ±Ρ€Π°Ρ‚Π½Ρ‹ΠΉ порядок ΠΊ matchURL). +func sourceMatchURL(src worker.SourceOption) string { + if src.URL != "" { + return src.URL + } + return providerURL(src.Provider, src.ProviderID, src.Type) +} + func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) { id, err := pathID(r) if err != nil { diff --git a/internal/worker/review.go b/internal/worker/review.go index 02b8f98..2e600a5 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "path/filepath" "strconv" "strings" @@ -569,20 +570,99 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) er if rec == nil || cand == nil || cand.RecognitionID != rec.ID { return fmt.Errorf("choose candidate: candidate %s does not belong to the current recognition", candidateID) } + return w.chooseCandidateLocked(ctx, id, d, rec, *cand) +} - pins := map[string]string{ovrProvider: cand.Provider, ovrProviderID: cand.ProviderID} - if cand.Title.Valid && cand.Title.String != "" { - pins[ovrTitle] = cand.Title.String +// AddManualSource добавляСт источник Π²Ρ€ΡƒΡ‡Π½ΡƒΡŽ ΠΏΠΎ (provider, id) ΠΈ Π²Ρ‹Π±ΠΈΡ€Π°Π΅Ρ‚ Π΅Π³ΠΎ. +// Когда автопоиск промахнулся: сохраняСм ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π° (Π΄Π΅Π΄ΡƒΠΏ ΠΏΠΎ provider:id) ΠΈ +// ΠΏΠΈΠ½Π½ΠΈΡ‚ ΠΊΠ°ΠΊ Π²Ρ‹Π±Ρ€Π°Π½Π½Ρ‹ΠΉ. provider β€” ΠΈΠ· Π½Π°Π±ΠΎΡ€Π° tmdb/tvdb/imdb. +func (w *Worker) AddManualSource(ctx context.Context, id, provider, providerID string) error { + provider = strings.TrimSpace(strings.ToLower(provider)) + providerID = strings.TrimSpace(providerID) + switch provider { + case "tmdb", "tvdb", "imdb": + default: + return fmt.Errorf("add source: invalid provider %q (tmdb/tvdb/imdb)", provider) } + if providerID == "" { + return fmt.Errorf("add source: empty id") + } + w.mu.Lock() + defer w.mu.Unlock() + + d, err := w.requireReviewable(ctx, id, "add source") + if err != nil { + return err + } + rec, err := w.store.GetCurrentRecognition(ctx, id) + if err != nil { + return fmt.Errorf("add source: %w", err) + } + if rec == nil { + return fmt.Errorf("add source: no recognition") + } + cand, err := w.findOrCreateCandidate(ctx, rec.ID, provider, providerID) + if err != nil { + return fmt.Errorf("add source: %w", err) + } + return w.chooseCandidateLocked(ctx, id, d, rec, *cand) +} + +// findOrCreateCandidate Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π° Ρ€Π΅ΠΊΠΎΠ³Π½ΠΈΡ†ΠΈΠΈ ΠΏΠΎ (provider, id), +// создавая Π΅Π³ΠΎ ΠΏΡ€ΠΈ отсутствии (Π΄Π΅Π΄ΡƒΠΏ ΠΏΠΎ provider:id). Под mu. +func (w *Worker) findOrCreateCandidate(ctx context.Context, recognitionID, provider, providerID string) (*store.MetadataCandidate, error) { + cands, err := w.store.ListCandidatesByRecognition(ctx, recognitionID) + if err != nil { + return nil, err + } + if c := findCandidate(cands, provider, providerID); c != nil { + return c, nil + } + if err := w.store.CreateCandidates(ctx, []store.MetadataCandidate{{ + RecognitionID: recognitionID, + Provider: provider, + ProviderID: providerID, + }}); err != nil { + return nil, err + } + cands, err = w.store.ListCandidatesByRecognition(ctx, recognitionID) + if err != nil { + return nil, err + } + if c := findCandidate(cands, provider, providerID); c != nil { + return c, nil + } + return nil, fmt.Errorf("candidate %s:%s not found after create", provider, providerID) +} + +func findCandidate(cands []store.MetadataCandidate, provider, providerID string) *store.MetadataCandidate { + for i := range cands { + if cands[i].Provider == provider && cands[i].ProviderID == providerID { + return &cands[i] + } + } + return nil +} + +// chooseCandidateLocked ΠΏΠΈΠ½Π½ΠΈΡ‚ ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π° ΠΊΠ°ΠΊ Π²Ρ‹Π±Ρ€Π°Π½Π½Ρ‹ΠΉ источник. ΠŸΠΈΡˆΠ΅Ρ‚ ΠŸΠžΠ›ΠΠ«Π™ +// самосогласованный Π½Π°Π±ΠΎΡ€ ΠΏΠΈΠ½ΠΎΠ² (provider/id/title/year): пустыС title/year Ρƒ +// ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π° ΠΎΡ‡ΠΈΡ‰Π°ΡŽΡ‚ Π²ΠΎΠ·ΠΌΠΎΠΆΠ½Ρ‹ΠΉ унаслСдованный ΠΏΠΈΠ½ ΠΏΡ€Π΅ΠΆΠ½Π΅Π³ΠΎ источника β€” ΠΈΠ½Π°Ρ‡Π΅ +// ΠΏΡ€Π΅Π²ΡŒΡŽ Ρ€Π°Π·ΠΎΡˆΠ»ΠΎΡΡŒ Π±Ρ‹ с ΠΏΡ€ΠΈΠΌΠ΅Π½Π΅Π½ΠΈΠ΅ΠΌ (Ρ€Π΅ΡˆΠ΅Π½ΠΈΠ΅ 1a). Под mu. +func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.Download, rec *store.Recognition, cand store.MetadataCandidate) error { + title := "" + if cand.Title.Valid { + title = cand.Title.String + } + year := 0 if cand.Year.Valid { - pins[ovrYear] = strconv.FormatInt(cand.Year.Int64, 10) + year = int(cand.Year.Int64) } - for field, value := range pins { + for field, value := range sourcePins(cand.Provider, cand.ProviderID, title, year) { if err := w.store.SetOverride(ctx, id, field, value); err != nil { return fmt.Errorf("choose candidate: %w", err) } } - if err := w.store.SetCandidateChosen(ctx, rec.ID, candidateID); err != nil { + if err := w.store.SetCandidateChosen(ctx, rec.ID, cand.ID); err != nil { return fmt.Errorf("choose candidate: %w", err) } logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen", @@ -609,18 +689,20 @@ func (w *Worker) SetProviderID(ctx context.Context, id string, provider, provide if err != nil { return err } - if err := w.store.SetOverride(ctx, id, ovrProvider, provider); err != nil { - return fmt.Errorf("set provider: %w", err) - } - if err := w.store.SetOverride(ctx, id, ovrProviderID, providerID); err != nil { - return fmt.Errorf("set provider: %w", err) + // ΠŸΠΎΠ»Π½Ρ‹ΠΉ Π½Π°Π±ΠΎΡ€ ΠΏΠΈΠ½ΠΎΠ²: id Π·Π°Π΄Π°Π½ Π²Ρ€ΡƒΡ‡Π½ΡƒΡŽ, Π½Π°Π·Π²Π°Π½ΠΈΠ΅/Π³ΠΎΠ΄ Π±Π΅Ρ€Ρ‘ΠΌ ΠΈΠ· ΠΏΠ»Π°Π½Π° + // (ΠΎΡ‡ΠΈΡ‰Π°Π΅ΠΌ Π²ΠΎΠ·ΠΌΠΎΠΆΠ½Ρ‹ΠΉ унаслСдованный ΠΏΠΈΠ½ ΠΏΡ€Π΅ΠΆΠ½Π΅Π³ΠΎ источника). + for field, value := range sourcePins(provider, providerID, "", 0) { + if err := w.store.SetOverride(ctx, id, field, value); err != nil { + return fmt.Errorf("set provider: %w", err) + } } logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider set", "provider", provider, "provider_id", providerID) return nil } -// ClearProvider β€” Β«Π±Π΅Π· Π±Π°Π·Ρ‹Β»: снимаСт ΠΌΠ°Ρ‚Ρ‡ (Ρ‚Π΅Π³ ΠΏΠ°ΠΏΠΊΠΈ Π½Π΅ ставится). +// ClearProvider β€” Β«Π±Π΅Π· Π±Π°Π·Ρ‹Β»: снимаСт ΠΌΠ°Ρ‚Ρ‡ (Ρ‚Π΅Π³ ΠΏΠ°ΠΏΠΊΠΈ Π½Π΅ ставится) ΠΈ ΠΎΡ‡ΠΈΡ‰Π°Π΅Ρ‚ +// ΠΏΠΈΠ½Ρ‹ названия/Π³ΠΎΠ΄Π° (источник β€” распознаваниС Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠΎΠΉ). func (w *Worker) ClearProvider(ctx context.Context, id string) error { w.mu.Lock() defer w.mu.Unlock() @@ -629,16 +711,33 @@ func (w *Worker) ClearProvider(ctx context.Context, id string) error { if err != nil { return err } - if err := w.store.SetOverride(ctx, id, ovrProvider, "none"); err != nil { - return fmt.Errorf("clear provider: %w", err) - } - if err := w.store.SetOverride(ctx, id, ovrProviderID, ""); err != nil { - return fmt.Errorf("clear provider: %w", err) + for field, value := range sourcePins("none", "", "", 0) { + if err := w.store.SetOverride(ctx, id, field, value); err != nil { + return fmt.Errorf("clear provider: %w", err) + } } logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider cleared") return nil } +// sourcePins β€” ΠΏΠΎΠ»Π½Ρ‹ΠΉ самосогласованный Π½Π°Π±ΠΎΡ€ ΠΏΠΈΠ½ΠΎΠ² источника (Ρ€Π΅ΡˆΠ΅Π½ΠΈΠ΅ 1a): +// title/year ΠΏΠΈΡˆΡƒΡ‚ΡΡ пустой строкой, Ссли Ρƒ источника ΠΈΡ… Π½Π΅Ρ‚; Π² applyOverrides +// пустая строка трактуСтся ΠΊΠ°ΠΊ Β«Π½Π΅Ρ‚ overrideΒ» β†’ бСрётся Π·Π½Π°Ρ‡Π΅Π½ΠΈΠ΅ ΠΏΠ»Π°Π½Π°. Π’Π°ΠΊ +// Π²Ρ‹Π±ΠΎΡ€ любого источника Π΄Π°Ρ‘Ρ‚ Π΄Π΅Ρ‚Π΅Ρ€ΠΌΠΈΠ½ΠΈΡ€ΠΎΠ²Π°Π½Π½Ρ‹ΠΉ эффСктивный ΠΏΠ»Π°Π½, Π° ΠΏΡ€Π΅Π²ΡŒΡŽ +// совпадаСт с ΠΏΡ€ΠΈΠΌΠ΅Π½Π΅Π½ΠΈΠ΅ΠΌ. Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ΡΡ ΠΈ Π² ΠΊΠΎΠΌΠΌΠΈΡ‚Π΅ (SetOverride), ΠΈ Π² ΠΏΡ€Π΅Π²ΡŒΡŽ. +func sourcePins(provider, providerID, title string, year int) map[string]string { + yr := "" + if year > 0 { + yr = strconv.Itoa(year) + } + return map[string]string{ + ovrProvider: provider, + ovrProviderID: providerID, + ovrTitle: title, + ovrYear: yr, + } +} + // --- Π”Π°Π½Π½Ρ‹Π΅ для экрана Ρ€Π΅Π²ΡŒΡŽ --- // ReviewData β€” всё, Ρ‡Ρ‚ΠΎ Π½ΡƒΠΆΠ½ΠΎ транспорту для отрисовки Ρ€Π΅Π²ΡŒΡŽ. @@ -646,14 +745,39 @@ type ReviewData struct { Download store.Download Recognition *store.Recognition Plan recognize.Plan // эффСктивный (с ΠΏΡ€ΠΈΠΌΠ΅Π½Ρ‘Π½Π½Ρ‹ΠΌΠΈ ΠΏΡ€Π°Π²ΠΊΠ°ΠΌΠΈ) - Preview []layout.Link // Ρ†Π΅Π»Π΅Π²Ρ‹Π΅ ΠΏΡƒΡ‚ΠΈ (Src β€” ΠΎΡ‚Π½ΠΎΡΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹ΠΉ, для ΠΏΠΎΠΊΠ°Π·Π°) + Preview []layout.Link // Ρ†Π΅Π»Π΅Π²Ρ‹Π΅ ΠΏΡƒΡ‚ΠΈ Π°ΠΊΡ‚ΠΈΠ²Π½ΠΎΠ³ΠΎ источника (Src β€” ΠΎΡ‚Π½ΠΎΡΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹ΠΉ) Candidates []store.MetadataCandidate // ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Ρ‹ Π±Π°Π·Ρ‹ для Ρ€ΡƒΡ‡Π½ΠΎΠ³ΠΎ Π²Ρ‹Π±ΠΎΡ€Π° + Sources []SourceOption // Π΅Π΄ΠΈΠ½Ρ‹ΠΉ список источников совпадСния (Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠ° + ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Ρ‹) Provider string // эффСктивный ΠΏΡ€ΠΎΠ²Π°ΠΉΠ΄Π΅Ρ€ (с ΡƒΡ‡Ρ‘Ρ‚ΠΎΠΌ Π²Ρ‹Π±ΠΎΡ€Π°) ProviderID string // эффСктивный id Π² Π±Π°Π·Π΅ Hints []string Overrides map[string]string } +// SourceKind β€” Π²ΠΈΠ΄ источника Π² Π΅Π΄ΠΈΠ½ΠΎΠΌ спискС Ρ€Π΅Π²ΡŒΡŽ. +type SourceKind string + +const ( + SourceNeural SourceKind = "neural" // распознаваниС Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠΎΠΉ (Π±Π΅Π· Π±Π°Π·Ρ‹) + SourceCandidate SourceKind = "candidate" // ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚ ΠΌΠ΅Ρ‚Π°Π±Π°Π·Ρ‹ (Π² Ρ‚.Ρ‡. Π΄ΠΎΠ±Π°Π²Π»Π΅Π½Π½Ρ‹ΠΉ Π²Ρ€ΡƒΡ‡Π½ΡƒΡŽ) +) + +// SourceOption β€” источник совпадСния Π² спискС Ρ€Π΅Π²ΡŒΡŽ: эффСктивныС поля ΠΈ +// прСдпросмотр Ρ†Π΅Π»Π΅Π²Ρ‹Ρ… ΠΏΡƒΡ‚Π΅ΠΉ, посчитанныС эфСмСрно (Π±Π΅Π· записи overrides). +type SourceOption struct { + Kind SourceKind + CandidateID string // ULID ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π° (пусто для Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠΈ) + Provider string // "none" для Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠΈ + ProviderID string + URL string // ссылка ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π° Π½Π° запись (Ссли Π΅ΡΡ‚ΡŒ) + Title string // эффСктивноС Π½Π°Π·Π²Π°Π½ΠΈΠ΅ для этого источника + Year int // эффСктивный Π³ΠΎΠ΄ + Type string // "movie" | "series" + Active bool // Ρ‚Π΅ΠΊΡƒΡ‰ΠΈΠΉ эффСктивный источник + Plan recognize.Plan // эффСктивный ΠΏΠ»Π°Π½ (для ΠΏΠΎΠΊΠ°Π·Π° Ρ„Π°ΠΉΠ»ΠΎΠ² β†’ раскладка) + Preview []layout.Link // Ρ†Π΅Π»Π΅Π²Ρ‹Π΅ ΠΏΡƒΡ‚ΠΈ этого источника +} + // ReviewData собираСт Π΄Π°Π½Π½Ρ‹Π΅ Ρ€Π΅Π²ΡŒΡŽ ΠΏΠΎ Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠ΅. func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error) { d, err := w.store.GetDownload(ctx, id) @@ -687,27 +811,98 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error) } } if rec != nil && rec.Plan.Valid { - var plan recognize.Plan - if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil { + var rawPlan recognize.Plan + if err := json.Unmarshal([]byte(rec.Plan.String), &rawPlan); err != nil { log.Warn("review data unmarshal plan failed", "error", err) } else { - plan = applyOverrides(plan, overrides) - rd.Plan = plan - // ΠŸΡ€Π΅Π²ΡŒΡŽ строим ΠΏΠΎ ΠΎΡ‚Π½ΠΎΡΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹ΠΌ путям с provider-Ρ‚Π΅Π³ΠΎΠΌ; ΠΎΡˆΠΈΠ±ΠΊΡƒ - // Π»ΠΎΠ³ΠΈΡ€ΡƒΠ΅ΠΌ Π½Π° Debug β€” просто ΠΏΠΎΠΊΠ°ΠΆΠ΅ΠΌ ΠΏΡ€ΠΈΡ‡ΠΈΠ½Ρ‹ Π±Π΅Π· ΠΏΡ€Π΅Π²ΡŒΡŽ. + rd.Plan = applyOverrides(rawPlan, overrides) + // ΠŸΡ€Π΅Π²ΡŒΡŽ Π°ΠΊΡ‚ΠΈΠ²Π½ΠΎΠ³ΠΎ источника строим ΠΏΠΎ ΠΎΡ‚Π½ΠΎΡΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹ΠΌ путям с + // provider-Ρ‚Π΅Π³ΠΎΠΌ; ΠΎΡˆΠΈΠ±ΠΊΡƒ Π»ΠΎΠ³ΠΈΡ€ΡƒΠ΅ΠΌ Π½Π° Debug β€” ΠΏΠΎΠΊΠ°ΠΆΠ΅ΠΌ Π±Π΅Π· ΠΏΡ€Π΅Π²ΡŒΡŽ. if w.layouter != nil { tag := providerTag(prov, pid) - if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "", tag)); lerr == nil { + if links, lerr := w.layouter.BuildLinks(toLayoutPlan(rd.Plan, "", tag)); lerr == nil { rd.Preview = links } else { log.Debug("review data build preview failed", "error", lerr) } } + // Π•Π΄ΠΈΠ½Ρ‹ΠΉ список источников: Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠ° + ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Ρ‹, ΠΊΠ°ΠΆΠ΄Ρ‹ΠΉ с + // эфСмСрным ΠΏΡ€Π΅Π²ΡŒΡŽ ΠΈΠ· сырого ΠΏΠ»Π°Π½Π° (Π±Π΅Π· записи overrides). + rd.Sources = w.buildSources(rawPlan, overrides, prov, pid, rd.Candidates) } } return rd, nil } +// buildSources собираСт Π΅Π΄ΠΈΠ½Ρ‹ΠΉ список источников: Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠ° (ΠΏΠ΅Ρ€Π²ΠΎΠΉ) + +// ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Ρ‹ (Π΄Π΅Π΄ΡƒΠΏ ΠΏΠΎ provider:id). Активным помСчаСтся Ρ‚Π΅ΠΊΡƒΡ‰ΠΈΠΉ эффСктивный +// источник. +func (w *Worker) buildSources(rawPlan recognize.Plan, overrides map[string]string, prov, pid string, cands []store.MetadataCandidate) []SourceOption { + neutral := prov == "" || prov == "none" + out := make([]SourceOption, 0, len(cands)+1) + out = append(out, w.sourceOption(SourceNeural, rawPlan, overrides, "", "none", "", "", "", 0, neutral)) + seen := map[string]bool{} + for _, c := range cands { + key := c.Provider + ":" + c.ProviderID + if seen[key] { + continue + } + seen[key] = true + title := "" + if c.Title.Valid { + title = c.Title.String + } + year := 0 + if c.Year.Valid { + year = int(c.Year.Int64) + } + active := !neutral && c.Provider == prov && c.ProviderID == pid + out = append(out, w.sourceOption(SourceCandidate, rawPlan, overrides, c.ID, c.Provider, c.ProviderID, c.URL.String, title, year, active)) + } + return out +} + +// sourceOption строит ΠΎΠ΄ΠΈΠ½ источник: Π½Π°ΠΊΠ»Π°Π΄Ρ‹Π²Π°Π΅Ρ‚ Π΅Π³ΠΎ ΠΏΠΈΠ½Ρ‹ Π½Π° нСисточниковыС +// overrides, считаСт эффСктивный ΠΏΠ»Π°Π½ ΠΈ прСдпросмотр ΠΏΡƒΡ‚Π΅ΠΉ β€” эфСмСрно, Π±Π΅Π· +// записи. Гарантия preview == apply: Ρ‚ΠΎΡ‚ ΠΆΠ΅ Π½Π°Π±ΠΎΡ€ ΠΏΠΈΠ½ΠΎΠ² Π·Π°ΠΏΠΈΡˆΠ΅Ρ‚ Π²Ρ‹Π±ΠΎΡ€. +func (w *Worker) sourceOption(kind SourceKind, rawPlan recognize.Plan, base map[string]string, candID, provider, providerID, url, title string, year int, active bool) SourceOption { + eff := applyOverrides(rawPlan, mergeSourceOverrides(base, sourcePins(provider, providerID, title, year))) + opt := SourceOption{ + Kind: kind, + CandidateID: candID, + Provider: provider, + ProviderID: providerID, + URL: url, + Title: eff.Title, + Year: eff.Year, + Type: string(eff.Type), + Active: active, + Plan: eff, + } + if w.layouter != nil { + if links, err := w.layouter.BuildLinks(toLayoutPlan(eff, "", providerTag(provider, providerID))); err == nil { + opt.Preview = links + } + } + return opt +} + +// mergeSourceOverrides Π½Π°ΠΊΠ»Π°Π΄Ρ‹Π²Π°Π΅Ρ‚ ΠΏΠΈΠ½Ρ‹ источника (provider/id/title/year) Π½Π° +// нСисточниковыС overrides (media_type, ignored_files, force_review, ...). +func mergeSourceOverrides(base, pins map[string]string) map[string]string { + m := make(map[string]string, len(base)+len(pins)) + for k, v := range base { + switch k { + case ovrProvider, ovrProviderID, ovrTitle, ovrYear: + continue + default: + m[k] = v + } + } + maps.Copy(m, pins) + return m +} + // effectivePlan Π·Π°Π³Ρ€ΡƒΠΆΠ°Π΅Ρ‚ Ρ‚Π΅ΠΊΡƒΡ‰ΠΈΠΉ ΠΏΠ»Π°Π½, примСняСт ΠΏΡ€Π°Π²ΠΊΠΈ ΠΈ Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ // provider-Ρ‚Π΅Π³ для ΠΈΠΌΠ΅Π½ΠΈ ΠΏΠ°ΠΏΠΊΠΈ (ΠΏΠΎΠ΄ mu). func (w *Worker) effectivePlan(ctx context.Context, id string) (recognize.Plan, string, error) { diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index f65694a..aed7b3a 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -1179,6 +1179,158 @@ func TestClearProvider(t *testing.T) { } } +// TestClearProvider_ClearsInheritedTitle β€” рСгрСссия Π½Π° залипший override +// (Ρ€Π΅ΡˆΠ΅Π½ΠΈΠ΅ 1a): послС Π²Ρ‹Π±ΠΎΡ€Π° Ρ‚ΠΈΡ‚ΡƒΠ»ΡŒΠ½ΠΎΠ³ΠΎ ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π° ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΠ΅ Π½Π° Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΡƒ +// Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ Π½Π°Π·Π²Π°Π½ΠΈΠ΅/Π³ΠΎΠ΄ ΠΏΠ»Π°Π½Π°, Π° Π½Π΅ унаслСдованныС ΠΎΡ‚ ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π°. +func TestClearProvider_ClearsInheritedTitle(t *testing.T) { + w, st := reviewWithCandidate(t, store.MetadataCandidate{ + Provider: "tvdb", ProviderID: "269613", + Title: store.NullString("Fargo"), Year: sql.NullInt64{Int64: 2014, Valid: true}, + }) + candID := st.candidates[0].ID + if err := w.ChooseCandidate(context.Background(), "1", candID); err != nil { + t.Fatalf("ChooseCandidate: %v", err) + } + if err := w.ClearProvider(context.Background(), "1"); err != nil { + t.Fatalf("ClearProvider: %v", err) + } + // НазваниС/Π³ΠΎΠ΄ снова ΠΈΠ· ΠΏΠ»Π°Π½Π° распознавания, Ρ‚Π΅Π³ пуст. + plan, tag, err := w.effectivePlan(context.Background(), "1") + if err != nil { + t.Fatalf("effectivePlan: %v", err) + } + if plan.Title != "Π”ΠΎΠ³Π°Π΄ΠΊΠ°" || plan.Year != 2000 { + t.Errorf("plan = %q (%d), want plan-defaults", plan.Title, plan.Year) + } + if tag != "" { + t.Errorf("tag = %q, want empty", tag) + } + if ov := st.overrides["1"]; ov[ovrTitle] != "" || ov[ovrYear] != "" { + t.Errorf("title/year override not cleared: %v", ov) + } +} + +// TestAddManualSource_ChoosesAndClears β€” Ρ€ΡƒΡ‡Π½ΠΎΠΉ источник создаётся, выбираСтся +// ΠΈ (Π±ΡƒΠ΄ΡƒΡ‡ΠΈ Π±Π΅Π·Ρ‚Π°ΠΉΡ‚Π»ΠΎΠ²Ρ‹ΠΌ) ΠΎΡ‡ΠΈΡ‰Π°Π΅Ρ‚ унаслСдованный ΠΏΠΈΠ½ названия. +func TestAddManualSource_ChoosesAndClears(t *testing.T) { + w, st := reviewWithCandidate(t, store.MetadataCandidate{ + Provider: "tvdb", ProviderID: "269613", + Title: store.NullString("Fargo"), Year: sql.NullInt64{Int64: 2014, Valid: true}, + }) + if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil { + t.Fatalf("ChooseCandidate: %v", err) + } + if err := w.AddManualSource(context.Background(), "1", "TMDB", " 603 "); err != nil { + t.Fatalf("AddManualSource: %v", err) + } + if len(st.candidates) != 2 { + t.Fatalf("candidates = %d, want 2 (manual added)", len(st.candidates)) + } + plan, tag, err := w.effectivePlan(context.Background(), "1") + if err != nil { + t.Fatalf("effectivePlan: %v", err) + } + if plan.Title != "Π”ΠΎΠ³Π°Π΄ΠΊΠ°" { // Π±Π΅Π·Ρ‚Π°ΠΉΡ‚Π»ΠΎΠ²Ρ‹ΠΉ источник β†’ Π½Π°Π·Π²Π°Π½ΠΈΠ΅ ΠΈΠ· ΠΏΠ»Π°Π½Π° + t.Errorf("plan title = %q, want plan-default", plan.Title) + } + if tag != "tmdbid-603" { + t.Errorf("tag = %q", tag) + } +} + +func TestAddManualSource_DedupAndInvalid(t *testing.T) { + w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "269613"}) + // Π”ΡƒΠ±Π»ΡŒ (provider:id ΡƒΠΆΠ΅ Π΅ΡΡ‚ΡŒ) β†’ Π½Π΅ ΠΏΠ»ΠΎΠ΄ΠΈΠΌ строку, Π²Ρ‹Π±ΠΈΡ€Π°Π΅ΠΌ ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΡƒΡŽ. + if err := w.AddManualSource(context.Background(), "1", "tvdb", "269613"); err != nil { + t.Fatalf("AddManualSource dup: %v", err) + } + if len(st.candidates) != 1 { + t.Errorf("candidates = %d, want 1 (dedup)", len(st.candidates)) + } + if !st.candidates[0].Chosen { + t.Error("ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠΉ ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚ Π΄ΠΎΠ»ΠΆΠ΅Π½ ΡΡ‚Π°Ρ‚ΡŒ Π²Ρ‹Π±Ρ€Π°Π½Π½Ρ‹ΠΌ") + } + if err := w.AddManualSource(context.Background(), "1", "kinopoisk", "1"); err == nil { + t.Error("нСдопустимый ΠΏΡ€ΠΎΠ²Π°ΠΉΠ΄Π΅Ρ€ Π΄ΠΎΠ»ΠΆΠ΅Π½ ΠΎΡ‚ΠΊΠ»ΠΎΠ½ΡΡ‚ΡŒΡΡ") + } + if err := w.AddManualSource(context.Background(), "1", "tmdb", " "); err == nil { + t.Error("пустой id Π΄ΠΎΠ»ΠΆΠ΅Π½ ΠΎΡ‚ΠΊΠ»ΠΎΠ½ΡΡ‚ΡŒΡΡ") + } +} + +func TestReviewData_BuildsSources(t *testing.T) { + w, st := reviewWithCandidate(t, store.MetadataCandidate{ + Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"), + }) + rd, err := w.ReviewData(context.Background(), "1") + if err != nil { + t.Fatalf("ReviewData: %v", err) + } + // ΠŸΠΎΡΡ‚Ρ€ΠΎΠ΅Π½ΠΈΠ΅ источников (Π² Ρ‚.Ρ‡. прСдпросмотр) Π½Π΅ Π΄ΠΎΠ»ΠΆΠ½ΠΎ ΠΏΠΈΡΠ°Ρ‚ΡŒ overrides. + if ov := st.overrides["1"]; len(ov) != 0 { + t.Errorf("ReviewData записал overrides: %v", ov) + } + if len(rd.Sources) != 2 { + t.Fatalf("sources = %d, want 2 (neural + candidate)", len(rd.Sources)) + } + if rd.Sources[0].Kind != SourceNeural || !rd.Sources[0].Active { + t.Errorf("source[0] = %+v, want активная Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠ°", rd.Sources[0]) + } + if rd.Sources[0].Title != "Π”ΠΎΠ³Π°Π΄ΠΊΠ°" { + t.Errorf("neural title = %q, want plan title", rd.Sources[0].Title) + } + if rd.Sources[1].Kind != SourceCandidate || rd.Sources[1].Title != "Fargo" || rd.Sources[1].Active { + t.Errorf("source[1] = %+v, want Π½Π΅Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹ΠΉ ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚ Fargo", rd.Sources[1]) + } +} + +// TestReviewData_SourcePreviewMatchesChosen β€” прСдпросмотр источника совпадаСт +// с ΠΏΡ€Π΅Π²ΡŒΡŽ Π°ΠΊΡ‚ΠΈΠ²Π½ΠΎΠ³ΠΎ источника послС Π΅Π³ΠΎ Π²Ρ‹Π±ΠΎΡ€Π° (эфСмСрноС ΠΏΡ€Π΅Π²ΡŒΡŽ == Ρ‚ΠΎ, Ρ‡Ρ‚ΠΎ +// примСнится). Π’Ρ€Π΅Π±ΡƒΠ΅Ρ‚ Ρ€Π΅Π°Π»ΡŒΠ½ΠΎΠ³ΠΎ layouter. +func TestReviewData_SourcePreviewMatchesChosen(t *testing.T) { + f := newApplyFixture(t, seriesResult().Plan) + _ = f.st.CreateCandidates(context.Background(), []store.MetadataCandidate{ + {RecognitionID: "1", Provider: "tvdb", ProviderID: "269613"}, + }) + candID := f.st.candidates[0].ID + + rd, err := f.w.ReviewData(context.Background(), "1") + if err != nil { + t.Fatalf("ReviewData: %v", err) + } + var srcPreview []layout.Link + for _, s := range rd.Sources { + if s.Kind == SourceCandidate && s.ProviderID == "269613" { + srcPreview = s.Preview + } + } + if len(srcPreview) == 0 { + t.Fatal("прСдпросмотр ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π° пуст") + } + if err := f.w.ChooseCandidate(context.Background(), "1", candID); err != nil { + t.Fatalf("ChooseCandidate: %v", err) + } + rd2, err := f.w.ReviewData(context.Background(), "1") + if err != nil { + t.Fatalf("ReviewData: %v", err) + } + if !sameLinks(srcPreview, rd2.Preview) { + t.Errorf("ΠΏΡ€Π΅Π²ΡŒΡŽ источника != ΠΏΡ€Π΅Π²ΡŒΡŽ Π°ΠΊΡ‚ΠΈΠ²Π½ΠΎΠ³ΠΎ:\n src=%v\n act=%v", srcPreview, rd2.Preview) + } +} + +func sameLinks(a, b []layout.Link) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Src != b[i].Src || a[i].Dst != b[i].Dst { + return false + } + } + return true +} + func TestReviewData_IncludesCandidates(t *testing.T) { w, st := reviewWithCandidate(t, store.MetadataCandidate{ Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"), diff --git a/openspec/changes/review-source-selection/tasks.md b/openspec/changes/review-source-selection/tasks.md index 932cf92..41c6a96 100644 --- a/openspec/changes/review-source-selection/tasks.md +++ b/openspec/changes/review-source-selection/tasks.md @@ -1,21 +1,21 @@ ## 1. Π―Π΄Ρ€ΠΎ: эфСмСрный прСдпросмотр источника (worker) -- [ ] 1.1 ВвСсти Ρ‡ΠΈΡΡ‚ΡƒΡŽ Π΄Π΅Ρ€ΠΈΠ²Π°Ρ†ΠΈΡŽ «источник β†’ ΠΏΠΎΠ»Π½Ρ‹ΠΉ самосогласованный Π½Π°Π±ΠΎΡ€ +- [x] 1.1 ВвСсти Ρ‡ΠΈΡΡ‚ΡƒΡŽ Π΄Π΅Ρ€ΠΈΠ²Π°Ρ†ΠΈΡŽ «источник β†’ ΠΏΠΎΠ»Π½Ρ‹ΠΉ самосогласованный Π½Π°Π±ΠΎΡ€ overridesΒ» (ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚ с title/year β†’ ΠΏΠΈΠ½Π½ΠΈΡ‚; Π±Π΅Π· β€” пустая строка Π² title/year; Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠ° β†’ provider=none + пустыС title/year) ΠΈ ΠΏΠ΅Ρ€Π΅ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ Π΅Ρ‘ Π² ΠΏΡ€Π΅Π²ΡŒΡŽ ΠΈ Π² ΠΊΠΎΠΌΠΌΠΈΡ‚Π΅ β€” гарантия preview == apply (Ρ€Π΅ΡˆΠ΅Π½ΠΈΠ΅ 1a design.md) -- [ ] 1.2 ΠŸΡ€ΠΈΠ²Π΅ΡΡ‚ΠΈ ΠΊΠΎΠΌΠ°Π½Π΄Ρ‹ Π²Ρ‹Π±ΠΎΡ€Π° ΠΊ этой Π΄Π΅Ρ€ΠΈΠ²Π°Ρ†ΠΈΠΈ: `ChooseCandidate` Ρƒ +- [x] 1.2 ΠŸΡ€ΠΈΠ²Π΅ΡΡ‚ΠΈ ΠΊΠΎΠΌΠ°Π½Π΄Ρ‹ Π²Ρ‹Π±ΠΎΡ€Π° ΠΊ этой Π΄Π΅Ρ€ΠΈΠ²Π°Ρ†ΠΈΠΈ: `ChooseCandidate` Ρƒ Π±Π΅Π·Ρ‚Π°ΠΉΡ‚Π»ΠΎΠ²ΠΎΠ³ΠΎ ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π° ΠΈ `ClearProvider` ΠΎΡ‡ΠΈΡ‰Π°ΡŽΡ‚ `ovrTitle`/`ovrYear` (пустой строкой) β€” Ρ‡ΠΈΠ½ΠΈΡ‚ Π»Π°Ρ‚Π΅Π½Ρ‚Π½Ρ‹ΠΉ залипший override -- [ ] 1.3 Π’Ρ‹Π΄Π΅Π»ΠΈΡ‚ΡŒ чистый расчёт Β«ΠΏΠ»Π°Π½ + Π½Π°Π±ΠΎΡ€ overrides источника β†’ поля + +- [x] 1.3 Π’Ρ‹Π΄Π΅Π»ΠΈΡ‚ΡŒ чистый расчёт Β«ΠΏΠ»Π°Π½ + Π½Π°Π±ΠΎΡ€ overrides источника β†’ поля + `[]layout.Link`Β» ΠΈΠ· Π»ΠΎΠ³ΠΈΠΊΠΈ `ReviewData` (ΠΏΠ΅Ρ€Π΅ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΡ `applyOverrides` + `toLayoutPlan` + `layouter.BuildLinks`), Π±Π΅Π· записи overrides Π² Π‘Π” -- [ ] 1.4 Π Π°ΡΡˆΠΈΡ€ΠΈΡ‚ΡŒ `ReviewData` срСзом источников: Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠ° (синтСтичСская +- [x] 1.4 Π Π°ΡΡˆΠΈΡ€ΠΈΡ‚ΡŒ `ReviewData` срСзом источников: Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠ° (синтСтичСская строка `provider=none`) + ΠΊΠ°ΠΆΠ΄Ρ‹ΠΉ ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚; для ΠΊΠ°ΠΆΠ΄ΠΎΠ³ΠΎ β€” эффСктивныС поля (Ρ‚ΠΈΠΏ/Π½Π°Π·Π²Π°Π½ΠΈΠ΅/Π³ΠΎΠ΄), ΠΏΡ€Π΅Π²ΡŒΡŽ ΠΏΡƒΡ‚Π΅ΠΉ ΠΈ ΠΏΡ€ΠΈΠ·Π½Π°ΠΊ Β«Π°ΠΊΡ‚ΠΈΠ²Π΅Π½Β»; Π΄Π΅Π΄ΡƒΠΏ источников ΠΏΠΎ `provider:id` (Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠ° β€” ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½Π°Ρ строка) -- [ ] 1.5 ВСсты worker: ΠΏΡ€Π΅Π²ΡŒΡŽ для Π½Π΅Π°ΠΊΡ‚ΠΈΠ²Π½ΠΎΠ³ΠΎ источника Π½Π΅ ΠΏΠΈΡˆΠ΅Ρ‚ overrides +- [x] 1.5 ВСсты worker: ΠΏΡ€Π΅Π²ΡŒΡŽ для Π½Π΅Π°ΠΊΡ‚ΠΈΠ²Π½ΠΎΠ³ΠΎ источника Π½Π΅ ΠΏΠΈΡˆΠ΅Ρ‚ overrides ΠΈ Π½Π΅ создаёт ссылок; ΠΏΡƒΡ‚ΠΈ ΠΏΡ€Π΅Π²ΡŒΡŽ ΡΠΎΠ²ΠΏΠ°Π΄Π°ΡŽΡ‚ с Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ΠΎΠΌ примСнСния Ρ‚ΠΎΠ³ΠΎ ΠΆΠ΅ источника; **ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΠ΅ с Ρ‚ΠΈΡ‚ΡƒΠ»ΡŒΠ½ΠΎΠ³ΠΎ ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π° Π½Π° Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΡƒ/ Π±Π΅Π·Ρ‚Π°ΠΉΡ‚Π»ΠΎΠ²Ρ‹ΠΉ источник Π½Π΅ тянСт Ρ‡ΡƒΠΆΠΈΠ΅ Π½Π°Π·Π²Π°Π½ΠΈΠ΅/Π³ΠΎΠ΄** (preview ΠΈ apply); @@ -23,46 +23,46 @@ ## 2. Π ΡƒΡ‡Π½ΠΎΠ΅ Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ΠΈΠ΅ источника (worker + store + httpapi) -- [ ] 2.1 ΠŸΠ°Ρ€ΡΠ΅Ρ€ Ρ€ΡƒΡ‡Π½ΠΎΠ³ΠΎ Π²Π²ΠΎΠ΄Π° Π² `httpapi`: id ΠΈΠ»ΠΈ URL записи ΠΌΠ΅Ρ‚Π°Π±Π°Π·Ρ‹ β†’ +- [x] 2.1 ΠŸΠ°Ρ€ΡΠ΅Ρ€ Ρ€ΡƒΡ‡Π½ΠΎΠ³ΠΎ Π²Π²ΠΎΠ΄Π° Π² `httpapi`: id ΠΈΠ»ΠΈ URL записи ΠΌΠ΅Ρ‚Π°Π±Π°Π·Ρ‹ β†’ `(provider, provider_id)` (ΠΎΠ±Ρ€Π°Ρ‚Π½Ρ‹ΠΉ ΠΊ `providerURL`: themoviedb.org movie/tv, thetvdb, imdb); валидация Π½Π° Π²Ρ…ΠΎΠ΄Π½ΠΎΠΉ Π³Ρ€Π°Π½ΠΈΡ†Π΅ -- [ ] 2.2 ΠœΠ΅Ρ‚ΠΎΠ΄ worker Β«Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ источник Π²Ρ€ΡƒΡ‡Π½ΡƒΡŽΒ»: ΡΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ +- [x] 2.2 ΠœΠ΅Ρ‚ΠΎΠ΄ worker Β«Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ источник Π²Ρ€ΡƒΡ‡Π½ΡƒΡŽΒ»: ΡΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ `metadata_candidate` (provider/id, ΠΎΠΏΡ†. url; title/year пустыС) с Π΄Π΅Π΄ΡƒΠΏΠΎΠΌ ΠΏΠΎ `provider:id`, Π·Π°Ρ‚Π΅ΠΌ Π²Ρ‹Π±Ρ€Π°Ρ‚ΡŒ Π΅Π³ΠΎ (ΠΊΠ°ΠΊ `ChooseCandidate`) -- [ ] 2.3 ΠžΠ±Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊ POST добавлСния Ρ€ΡƒΡ‡Π½ΠΎΠ³ΠΎ источника + ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚; ΠΎΡ‚ΠΊΠ»ΠΎΠ½ΡΡ‚ΡŒ +- [x] 2.3 ΠžΠ±Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊ POST добавлСния Ρ€ΡƒΡ‡Π½ΠΎΠ³ΠΎ источника + ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚; ΠΎΡ‚ΠΊΠ»ΠΎΠ½ΡΡ‚ΡŒ Π½Π΅ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½Ρ‹ΠΉ Π²Π²ΠΎΠ΄ сообщСниСм, Π½Π΅ мСняя Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹ΠΉ источник -- [ ] 2.4 ВСсты: URL β†’ provider+id; Π½Π΅Π²Π°Π»ΠΈΠ΄Π½Ρ‹ΠΉ Π²Π²ΠΎΠ΄ ΠΎΡ‚ΠΊΠ»ΠΎΠ½Ρ‘Π½; Π΄ΡƒΠ±Π»ΡŒ id Π½Π΅ +- [x] 2.4 ВСсты: URL β†’ provider+id; Π½Π΅Π²Π°Π»ΠΈΠ΄Π½Ρ‹ΠΉ Π²Π²ΠΎΠ΄ ΠΎΡ‚ΠΊΠ»ΠΎΠ½Ρ‘Π½; Π΄ΡƒΠ±Π»ΡŒ id Π½Π΅ ΠΏΠ»ΠΎΠ΄ΠΈΡ‚ строку, Π° Π²Ρ‹Π±ΠΈΡ€Π°Π΅Ρ‚ ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΡƒΡŽ ## 3. Π‘Ρ‚Ρ€Π°Π½ΠΈΡ†Π° Ρ€Π΅Π²ΡŒΡŽ: Π΅Π΄ΠΈΠ½Ρ‹ΠΉ список ΠΈ прСдпросмотр (httpapi + шаблон) -- [ ] 3.1 ΠŸΠ΅Ρ€Π΅Ρ€Π°Π±ΠΎΡ‚Π°Ρ‚ΡŒ Π±Π»ΠΎΠΊ Β«Π˜ΡΡ‚ΠΎΡ‡Π½ΠΈΠΊ совпадСния» Π² Π΅Π΄ΠΈΠ½Ρ‹ΠΉ список строк ΠΈΠ· +- [x] 3.1 ΠŸΠ΅Ρ€Π΅Ρ€Π°Π±ΠΎΡ‚Π°Ρ‚ΡŒ Π±Π»ΠΎΠΊ Β«Π˜ΡΡ‚ΠΎΡ‡Π½ΠΈΠΊ совпадСния» Π² Π΅Π΄ΠΈΠ½Ρ‹ΠΉ список строк ΠΈΠ· `[]SourceOption`: Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠ° (ΠΏΠ΅Ρ€Π²ΠΎΠΉ) + ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Ρ‹ Π±Π°Π· + Ρ€ΡƒΡ‡Π½ΠΎΠΉ Π²Π²ΠΎΠ΄; Ρ€ΠΎΠ²Π½ΠΎ ΠΎΠ΄ΠΈΠ½ Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹ΠΉ, Ρƒ Π°ΠΊΡ‚ΠΈΠ²Π½ΠΎΠ³ΠΎ β€” ΠΌΠ°Ρ‚Ρ‡-ссылка (ΠΊΠ°ΠΊ сСйчас) -- [ ] 3.2 Π‘Ρ‚Ρ€ΠΎΠΊΠ° источника: шапка (ΠΏΡ€ΠΎΠ²Π°ΠΉΠ΄Π΅Ρ€/Π½Π°Π·Π²Π°Π½ΠΈΠ΅/Π³ΠΎΠ΄ + ΠΌΠ°Ρ€ΠΊΠ΅Ρ€ Π°ΠΊΡ‚ΠΈΠ²Π½ΠΎΠ³ΠΎ +- [x] 3.2 Π‘Ρ‚Ρ€ΠΎΠΊΠ° источника: шапка (ΠΏΡ€ΠΎΠ²Π°ΠΉΠ΄Π΅Ρ€/Π½Π°Π·Π²Π°Π½ΠΈΠ΅/Π³ΠΎΠ΄ + ΠΌΠ°Ρ€ΠΊΠ΅Ρ€ Π°ΠΊΡ‚ΠΈΠ²Π½ΠΎΠ³ΠΎ + ΠΊΠ½ΠΎΠΏΠΊΠ° Β«Π²Ρ‹Π±Ρ€Π°Ρ‚ΡŒΒ») ΠΈ **раскрываСмый ΠΏΠΎ ΠΊΠ»ΠΈΠΊΡƒ** Π±Π»ΠΎΠΊ прСдпросмотра β€” поля (Ρ‚ΠΈΠΏ/Π½Π°Π·Π²Π°Π½ΠΈΠ΅/Π³ΠΎΠ΄ + пустоС мСсто ΠΏΠΎΠ΄ рСТиссёра) ΠΈ Ρ†Π΅Π»Π΅Π²Ρ‹Π΅ ΠΏΡƒΡ‚ΠΈ; Π΄Π°Π½Π½Ρ‹Π΅ всСх источников прСдпосчитаны сСрвСром ΠΈ Π²Π»ΠΎΠΆΠ΅Π½Ρ‹ Π² страницу -- [ ] 3.3 Π‘Π΅ΠΊΡ†ΠΈΡŽ «Раскладка» ΠΎΡΡ‚Π°Π²ΠΈΡ‚ΡŒ ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½ΠΎΠΉ β€” ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅Ρ‚ ΠΏΡƒΡ‚ΠΈ **Π°ΠΊΡ‚ΠΈΠ²Π½ΠΎΠ³ΠΎ** +- [x] 3.3 Π‘Π΅ΠΊΡ†ΠΈΡŽ «Раскладка» ΠΎΡΡ‚Π°Π²ΠΈΡ‚ΡŒ ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½ΠΎΠΉ β€” ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅Ρ‚ ΠΏΡƒΡ‚ΠΈ **Π°ΠΊΡ‚ΠΈΠ²Π½ΠΎΠ³ΠΎ** источника (ΠΊΠ°ΠΊ сСйчас); ΠΈΠ½Π»Π°ΠΉΠ½-ΠΏΡ€Π΅Π²ΡŒΡŽ Π² строках β€” Ρ‚ΠΎΠ»ΡŒΠΊΠΎ для сравнСния Π½Π΅Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹Ρ… Π΄ΠΎ Π²Ρ‹Π±ΠΎΡ€Π° -- [ ] 3.4 ΠšΠ»ΠΈΠ΅Π½Ρ‚ΡΠΊΠΈΠΉ vanilla-JS: раскрытиС/скрытиС прСдпосчитанного ΠΏΡ€Π΅Π²ΡŒΡŽ +- [x] 3.4 ΠšΠ»ΠΈΠ΅Π½Ρ‚ΡΠΊΠΈΠΉ vanilla-JS: раскрытиС/скрытиС прСдпосчитанного ΠΏΡ€Π΅Π²ΡŒΡŽ строки ΠΏΠΎ ΠΊΠ»ΠΈΠΊΡƒ (Π½Π°Ρ‚ΠΈΠ²Π½Ρ‹ΠΉ `
` ΠΈΠ»ΠΈ ΠΌΠΈΠ½ΠΈΠΌΠ°Π»ΡŒΠ½Ρ‹ΠΉ JS); Π±Π΅Π· Π΄ΠΎΠΌΠ΅Π½Π½ΠΎΠ³ΠΎ пСрСсчёта Π½Π° ΠΊΠ»ΠΈΠ΅Π½Ρ‚Π΅ -- [ ] 3.5 ДСйствия строк Ρ„ΠΎΡ€ΠΌΠ°ΠΌΠΈ-Ρ€Π°ΡƒΠ½Π΄Ρ‚Ρ€ΠΈΠΏΠ°ΠΌΠΈ: Π²Ρ‹Π±Ρ€Π°Ρ‚ΡŒ ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π° +- [x] 3.5 ДСйствия строк Ρ„ΠΎΡ€ΠΌΠ°ΠΌΠΈ-Ρ€Π°ΡƒΠ½Π΄Ρ‚Ρ€ΠΈΠΏΠ°ΠΌΠΈ: Π²Ρ‹Π±Ρ€Π°Ρ‚ΡŒ ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π° (`ChooseCandidate`), ΡΠ½ΡΡ‚ΡŒ Π² ΠΏΠΎΠ»ΡŒΠ·Ρƒ Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠΈ (`ClearProvider`), Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π²Ρ€ΡƒΡ‡Π½ΡƒΡŽ; ΡΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ ΠΏΡ€Π΅ΠΆΠ½ΠΈΠ΅ дСйствия Ρ€Π΅Π²ΡŒΡŽ -- [ ] 3.6 Π£Π±Π΅Π΄ΠΈΡ‚ΡŒΡΡ, Ρ‡Ρ‚ΠΎ ΠΌΠ°Ρ‚Ρ‡-ссылка ΠΈ Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΎΠΊ строки ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½Ρ‹ для Ρ€ΡƒΡ‡Π½ΠΎΠ³ΠΎ +- [x] 3.6 Π£Π±Π΅Π΄ΠΈΡ‚ΡŒΡΡ, Ρ‡Ρ‚ΠΎ ΠΌΠ°Ρ‚Ρ‡-ссылка ΠΈ Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΎΠΊ строки ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½Ρ‹ для Ρ€ΡƒΡ‡Π½ΠΎΠ³ΠΎ ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π° с пустыми title/year (дСградация Π΄ΠΎ Β«ΠΏΡ€ΠΎΠ²Π°ΠΉΠ΄Π΅Ρ€ + idΒ») ## 4. ΠŸΡ€ΠΎΠ²Π΅Ρ€ΠΊΠ° ΠΈ ΠΏΡ€ΠΈΡ‘ΠΌΠΊΠ° -- [ ] 4.1 `task test` ΠΈ `task lint` Π·Π΅Π»Ρ‘Π½Ρ‹Π΅ -- [ ] 4.2 Ручная ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠ° сцСнариСв спСки: Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠ°-строка, ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΠ΅ +- [x] 4.1 `task test` ΠΈ `task lint` Π·Π΅Π»Ρ‘Π½Ρ‹Π΅ +- [x] 4.2 Ручная ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠ° сцСнариСв спСки: Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠ°-строка, ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΠ΅ ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚ΠΎΠ², снятиС Π² ΠΏΠΎΠ»ΡŒΠ·Ρƒ Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠΈ, Ρ€ΡƒΡ‡Π½ΠΎΠΉ Π²Π²ΠΎΠ΄ ΠΏΠΎ URL, прСдпросмотр Π±Π΅Π· фиксации -- [ ] 4.3 `openspec validate review-source-selection --strict` -- [ ] 4.4 ΠžΠ±Π½ΠΎΠ²ΠΈΡ‚ΡŒ `docs/specs/review-ux.md` (источник истины ΠΏΠΎ review-Π΄ΠΎΠΌΠ΅Π½Ρƒ +- [x] 4.3 `openspec validate review-source-selection --strict` +- [x] 4.4 ΠžΠ±Π½ΠΎΠ²ΠΈΡ‚ΡŒ `docs/specs/review-ux.md` (источник истины ΠΏΠΎ review-Π΄ΠΎΠΌΠ΅Π½Ρƒ Π΄ΠΎ ΠΌΠΈΠ³Ρ€Π°Ρ†ΠΈΠΈ): Π΅Π΄ΠΈΠ½Ρ‹ΠΉ список источников, Ρ€ΡƒΡ‡Π½ΠΎΠ΅ Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ΠΈΠ΅, прСдпросмотр, ΠΎΠ±ΡŠΡ‘ΠΌ Π€3/Π€5 β€” ΠΎΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½ΠΎ (ΠΏΠΎΠ²Π΅Π΄Π΅Π½ΠΈΠ΅ экрана мСняСтся); ΡƒΠ±Ρ€Π°Ρ‚ΡŒ ΠΏΡƒΠ½ΠΊΡ‚ Π±Π΅ΠΊΠ»ΠΎΠ³Π° послС archive diff --git a/web/templates/review.html b/web/templates/review.html index d96ad99..ce34ba2 100644 --- a/web/templates/review.html +++ b/web/templates/review.html @@ -66,42 +66,52 @@
-

Π˜ΡΡ‚ΠΎΡ‡Π½ΠΈΠΊ совпадСния

Π²Ρ‹Π±Π΅Ρ€ΠΈ Π±Π°Π·Ρƒ ΠΈΠ»ΠΈ ΠΎΡΡ‚Π°Π²ΡŒ распознаваниС Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠΎΠΉ
+

Π˜ΡΡ‚ΠΎΡ‡Π½ΠΈΠΊ совпадСния

Π²Ρ‹Π±Π΅Ρ€ΠΈ источник β€” распознаваниС Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠΎΠΉ ΠΈΠ»ΠΈ запись Π±Π°Π·Ρ‹
- {{if .Provider}}

Π’Ρ‹Π±Ρ€Π°Π½ΠΎ: {{.Provider}} {{if .MatchURL}}{{.ProviderID}} β†—{{else}}{{.ProviderID}}{{end}}

- {{else if .NoBase}}

Π’Ρ‹Π±Ρ€Π°Π½ΠΎ: Π±Π΅Π· Π±Π°Π·Ρ‹ (Ρ‚Π΅Π³ ΠΏΠ°ΠΏΠΊΠΈ Π½Π΅ ставится)

- {{else}}

ΠœΠ°Ρ‚Ρ‡ Π½Π΅ ΠΏΠΎΠ΄Ρ‚Π²Π΅Ρ€ΠΆΠ΄Ρ‘Π½ β€” Π²Ρ‹Π±Π΅Ρ€ΠΈ ΠΊΠ°Π½Π΄ΠΈΠ΄Π°Ρ‚Π°, Π²Π²Π΅Π΄ΠΈ id ΠΈΠ»ΠΈ Β«Π±Π΅Π· Π±Π°Π·Ρ‹Β».

{{end}} - - {{if .Candidates}}
- {{range .Candidates}} -
+ {{range .Sources}} +
-
{{.Provider}} {{.Title}}{{if .Year}} Β· {{.Year}}{{end}}
-
id {{.ProviderID}}
+ {{if eq .Kind "neural"}} +
Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠ° распознано Π½Π΅ΠΉΡ€ΠΎΠ½ΠΊΠΎΠΉ
+
Π±Π΅Π· Π±Π°Π·Ρ‹ β€” Ρ‚Π΅Π³ ΠΏΠ°ΠΏΠΊΠΈ Π½Π΅ ставится
+ {{else}} +
{{.Provider}} {{if .Title}}{{.Title}}{{else}}id {{.ProviderID}}{{end}}{{if .Year}} Β· {{.Year}}{{end}}
+
id {{.ProviderID}}{{if .MatchURL}} Β· запись β†—{{end}}
+ {{end}} + {{if not .Active}} +
+ прСдпросмотр +
+ Π’ΠΈΠΏ {{if .IsSeries}}сСриал{{else}}Ρ„ΠΈΠ»ΡŒΠΌ{{end}} Β· + НазваниС {{.Title}}{{if .Year}} ({{.Year}}){{end}} Β· + РСТиссёр β€” +
+ {{template "layout_widget" .Files}} +
+ {{end}}
- {{if .URL}}ссылка β†—{{end}} -
- - + {{if .Active}} + + {{else}} + + {{if ne .Kind "neural"}}{{end}} +
+ {{end}}
{{end}}
- {{end}} -
+ - - -
-
- + +