recognize: название из метабазы санитизируется перед попаданием в план
- чистка стоит на каждой точке входа значения метабазы в план — сборка матча, копия кандидата для ревью, набор закреплённых значений источника и его чтение: гарантия, поставленная только на запись, обходится данными, сохранёнными прежними версиями - название, непригодное как имя каталога (пустое или без единой буквы и цифры), не подставляется — раздача уходит в review с названной причиной - гейт подтверждения матча не сдвинут: сравнение с планом идёт по значениям провайдера, чистится только копия, уходящая дальше
This commit is contained in:
@@ -812,7 +812,8 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.
|
||||
if w.recognizer != nil {
|
||||
director = w.recognizer.Director(ctx, candMediaType(rec), cand.Provider, cand.ProviderID)
|
||||
}
|
||||
for field, value := range sourcePins(cand.Provider, cand.ProviderID, title, year, director) {
|
||||
pins := sourcePins(cand.Provider, cand.ProviderID, title, year, director)
|
||||
for field, value := range pins {
|
||||
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
|
||||
return fmt.Errorf("choose candidate: %w", err)
|
||||
}
|
||||
@@ -820,8 +821,13 @@ func (w *Worker) chooseCandidateLocked(ctx context.Context, id string, d *store.
|
||||
if err := w.store.SetCandidateChosen(ctx, rec.ID, cand.ID); err != nil {
|
||||
return fmt.Errorf("choose candidate: %w", err)
|
||||
}
|
||||
// title_pinned=false означает, что название кандидата не годится как имя
|
||||
// каталога и в плане осталось название распознавания. Без этого атрибута
|
||||
// вопрос «почему папка названа догадкой, а не как в базе» по логам не
|
||||
// разбирается: на авто-пути такой отказ несёт причина решения, здесь её нет.
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen",
|
||||
"provider", cand.Provider, "provider_id", cand.ProviderID)
|
||||
"provider", cand.Provider, "provider_id", cand.ProviderID,
|
||||
"title_pinned", pins[ovrTitle] != "")
|
||||
// Подтверждённый матч — переливаем каноническое имя в display_name и в ярлык
|
||||
// раздачи (best-effort, косметика). Сбой обновления имени не должен ронять
|
||||
// выбор кандидата: логируем и продолжаем.
|
||||
@@ -911,6 +917,18 @@ func sourcePins(provider, providerID, title string, year int, director string) m
|
||||
if year > 0 {
|
||||
yr = strconv.Itoa(year)
|
||||
}
|
||||
// Название источника — недоверенное значение метабазы, и чистится оно здесь,
|
||||
// на единственном общем доме набора пинов: через sourcePins идут и превью
|
||||
// источника, и его закрепление, поэтому «превью = применение» держится
|
||||
// конструкцией, а не памятью. Чистка идемпотентна — на кандидате, записанном
|
||||
// уже с чисткой, она ничего не меняет, а строку из БД, сохранённую прежней
|
||||
// версией, приводит в порядок. Непригодное как имя каталога название пином не
|
||||
// становится: пустое значение очищает пин, и в плане остаётся название
|
||||
// распознавания.
|
||||
title = recognize.SanitizeTitle(title)
|
||||
if !recognize.UsableTitle(title) {
|
||||
title = ""
|
||||
}
|
||||
return map[string]string{
|
||||
ovrProvider: provider,
|
||||
ovrProviderID: providerID,
|
||||
@@ -1297,8 +1315,15 @@ func (w *Worker) resolveFolderBase(ctx context.Context, downloadID, provider, pr
|
||||
// applyOverrides применяет ручные правки к плану: каноническое имя/год (из
|
||||
// выбранного кандидата базы) и помечает игнорируемые файлы ролью ignore (их
|
||||
// раскладка пропустит).
|
||||
//
|
||||
// Название чистится здесь, НА ЧТЕНИИ, и это не дубль чистки в sourcePins: та
|
||||
// держит хранилище чистым, а эта защищает от значений, записанных прежними
|
||||
// версиями. Пин, закреплённый до появления санитайзинга, иначе доезжает до имени
|
||||
// каталога дословно при обычном «Применить» — без повторного выбора источника
|
||||
// запись в хранилище никто не перепишет. Чистка идемпотентна, так что на пинах,
|
||||
// записанных уже с ней, обе не делают ничего.
|
||||
func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan {
|
||||
if t := overrides[ovrTitle]; t != "" {
|
||||
if t := recognize.SanitizeTitle(overrides[ovrTitle]); recognize.UsableTitle(t) {
|
||||
plan.Title = t
|
||||
}
|
||||
if y := overrides[ovrYear]; y != "" {
|
||||
|
||||
@@ -2285,3 +2285,124 @@ func TestSweepLinking_LeavesOtherStates(t *testing.T) {
|
||||
t.Errorf("review task moved to %q", st.downloads["2"].State)
|
||||
}
|
||||
}
|
||||
|
||||
// Кандидат с непригодным названием (голая пунктуация — TVDB правится
|
||||
// сообществом, мусорные записи там штатны): пин названия не ставится, и в
|
||||
// эффективном плане остаётся название распознавания. Иначе в библиотеке
|
||||
// появился бы каталог «- (2000)», а на «.» раскладка упала бы ошибкой слоя
|
||||
// layout — тот же исход, что и у канонического названия на авто-пути.
|
||||
func TestChooseCandidate_UnusableTitleNotPinned(t *testing.T) {
|
||||
for _, title := range []string{"-", ".", "..."} {
|
||||
t.Run(title, func(t *testing.T) {
|
||||
w, st := reviewWithCandidate(t, store.MetadataCandidate{
|
||||
Provider: "tvdb", ProviderID: "269613",
|
||||
Title: store.NullString(title),
|
||||
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 ov := st.overrides["1"]; ov[ovrTitle] != "" {
|
||||
t.Errorf("непригодное название закреплено: %q", ov[ovrTitle])
|
||||
}
|
||||
// Провайдер, id и год закрепляются как обычно — матч верен.
|
||||
if ov := st.overrides["1"]; ov[ovrProviderID] != "269613" || ov[ovrYear] != "2014" {
|
||||
t.Errorf("overrides = %v", st.overrides["1"])
|
||||
}
|
||||
plan, _, _, err := w.effectivePlan(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("effectivePlan: %v", err)
|
||||
}
|
||||
if plan.Title != "Догадка" {
|
||||
t.Errorf("plan.Title = %q, want название распознавания", plan.Title)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Кандидат, сохранённый ПРЕЖНЕЙ версией (до чистки на входе в список), лежит в
|
||||
// БД грязным. Гарантия чистоты не может держаться на времени записи: чистка
|
||||
// стоит на закреплении, в sourcePins, и идемпотентна — на новых строках это
|
||||
// no-op. Без неё невидимка доезжает до имени каталога, потому что
|
||||
// layout.sanitizeComponent категорию Cf не трогает.
|
||||
func TestChooseCandidate_DirtyLegacyTitleSanitized(t *testing.T) {
|
||||
w, st := reviewWithCandidate(t, store.MetadataCandidate{
|
||||
Provider: "tvdb", ProviderID: "269613",
|
||||
Title: store.NullString("Far\u200bgo"),
|
||||
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 got := st.overrides["1"][ovrTitle]; got != "Fargo" {
|
||||
t.Errorf("закреплено %q, want %q — грязная строка из БД не очищена", got, "Fargo")
|
||||
}
|
||||
plan, _, _, err := w.effectivePlan(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("effectivePlan: %v", err)
|
||||
}
|
||||
if plan.Title != "Fargo" {
|
||||
t.Errorf("plan.Title = %q", plan.Title)
|
||||
}
|
||||
}
|
||||
|
||||
// Превью = применение: строка источника показывает ровно то название, которое
|
||||
// закрепится по клику. Прежде превью считалось из сырого названия кандидата, а
|
||||
// гейт пригодности стоял только на закреплении — экран обещал одно, раскладка
|
||||
// делала другое.
|
||||
func TestBuildSources_PreviewMatchesApply(t *testing.T) {
|
||||
for _, c := range []struct{ name, dbTitle, want string }{
|
||||
{"непригодное название", "-", "Догадка"},
|
||||
{"грязное название", "Far\u200bgo", "Fargo"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
w, st := reviewWithCandidate(t, store.MetadataCandidate{
|
||||
Provider: "tvdb", ProviderID: "269613",
|
||||
Title: store.NullString(c.dbTitle),
|
||||
Year: sql.NullInt64{Int64: 2014, Valid: true},
|
||||
})
|
||||
rd, err := w.ReviewData(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewData: %v", err)
|
||||
}
|
||||
var src *SourceOption
|
||||
for i := range rd.Sources {
|
||||
if rd.Sources[i].Kind == SourceCandidate {
|
||||
src = &rd.Sources[i]
|
||||
}
|
||||
}
|
||||
if src == nil {
|
||||
t.Fatal("кандидат не попал в источники")
|
||||
}
|
||||
if src.Title != c.want {
|
||||
t.Errorf("превью показывает %q, want %q", src.Title, c.want)
|
||||
}
|
||||
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
|
||||
t.Fatalf("ChooseCandidate: %v", err)
|
||||
}
|
||||
plan, _, _, err := w.effectivePlan(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("effectivePlan: %v", err)
|
||||
}
|
||||
if plan.Title != src.Title {
|
||||
t.Errorf("превью %q, применилось %q — расхождение", src.Title, plan.Title)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Пин, закреплённый ПРЕЖНЕЙ версией, лежит в overrides грязным, и обычное
|
||||
// «Применить» его не переписывает — источник заново не выбирают. Поэтому чистка
|
||||
// стоит и на чтении: иначе раздача, стоящая в ревью на момент выката, создаёт
|
||||
// ровно тот каталог, ради которого затевалась правка.
|
||||
func TestApplyOverrides_LegacyDirtyPinSanitized(t *testing.T) {
|
||||
plan := recognize.Plan{Type: recognize.MediaMovie, Title: "Догадка", Year: 2000}
|
||||
got := applyOverrides(plan, map[string]string{ovrTitle: "Fa\u200brgo"})
|
||||
if got.Title != "Fargo" {
|
||||
t.Errorf("plan.Title = %q, want %q — грязный пин доехал до раскладки", got.Title, "Fargo")
|
||||
}
|
||||
// Непригодный пин названием не становится: остаётся название распознавания.
|
||||
if got := applyOverrides(plan, map[string]string{ovrTitle: "-"}); got.Title != "Догадка" {
|
||||
t.Errorf("plan.Title = %q, want название распознавания", got.Title)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user