Фикс повторного распознавания
This commit is contained in:
@@ -54,7 +54,7 @@ ingest → downloading → completed → recognizing ──┬─ авто ─
|
||||
└─ failed ⇄ retry
|
||||
|
||||
done → undo → reverted
|
||||
reverted → «Привязать заново» → recognizing (ручная перепривязка, всегда через review)
|
||||
reverted/cancelled → «Привязать заново» → recognizing (ручная перепривязка, всегда через review)
|
||||
review → «Позже» → deferred → review
|
||||
любой → «Отклонить» → cancelled
|
||||
```
|
||||
@@ -75,10 +75,13 @@ review → «Позже» → deferred → review
|
||||
созданные ссылки).
|
||||
- **deferred / cancelled / failed / stuck** — «Позже», «Отклонить»,
|
||||
ошибка (ретраибельна), не качается дольше таймаута.
|
||||
- **reverted → recognizing** — «Привязать заново»: после отката можно
|
||||
перезапустить распознавание для той же раздачи. Перепривязка всегда идёт
|
||||
через review с ручным подтверждением (авто-раскладку не делаем), и требует,
|
||||
чтобы раздача всё ещё была в qBittorrent.
|
||||
- **reverted / cancelled → recognizing** — «Привязать заново»: после отката
|
||||
или отклонения можно перезапустить распознавание для той же раздачи.
|
||||
Перепривязка всегда идёт через review с ручным подтверждением (авто-раскладку
|
||||
не делаем), и требует, чтобы раздача всё ещё была в qBittorrent.
|
||||
- **review → recognizing** — кроме «Уточнить» (подсказка + перераспознавание)
|
||||
есть «Распознать заново»: повторный прогон распознавания без новой подсказки,
|
||||
по уже накопленному контексту и подсказкам.
|
||||
|
||||
Все переходы и команды идут через `worker` под per-download блокировкой —
|
||||
два транспорта не гонятся за одно состояние. Состояние персистентно в
|
||||
|
||||
@@ -123,10 +123,15 @@ Telegram = одобрить / подсказать / выбрать кандид
|
||||
действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo**
|
||||
после применения → `reverted` (удаляет только ссылки своего батча, под
|
||||
`media`). Полная карта состояний — в [architecture.md](architecture.md).
|
||||
- После отката доступна **«Привязать заново»**: перезапускает распознавание
|
||||
для той же раздачи (`reverted → recognizing`) и снова приводит в review —
|
||||
раскладка всегда требует ручного подтверждения, авто не делаем. Нужна,
|
||||
когда распознали неверно: откатил, перепривязал, поправил и применил.
|
||||
- После отката или отклонения доступна **«Привязать заново»**: перезапускает
|
||||
распознавание для той же раздачи (`reverted`/`cancelled → recognizing`) и
|
||||
снова приводит в review — раскладка всегда требует ручного подтверждения,
|
||||
авто не делаем. Нужна, когда распознали неверно: откатил/отклонил,
|
||||
перепривязал, поправил и применил.
|
||||
- В самом ревью, помимо **«Уточнить»** (подсказка + перераспознавание), есть
|
||||
**«Распознать заново»** — повторный прогон распознавания без новой подсказки
|
||||
(контекст и прежние подсказки уже учтены). Полезно, когда модель один раз
|
||||
споткнулась на разовой ошибке.
|
||||
|
||||
## Объём по версиям
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ func NewRouter(d Deps) (http.Handler, error) {
|
||||
r.Get("/review/{id}", s.handleReview)
|
||||
r.Post("/ui/downloads/{id}/apply", s.handleApply)
|
||||
r.Post("/ui/downloads/{id}/refine", s.handleRefine)
|
||||
r.Post("/ui/downloads/{id}/rerecognize", s.handleRerecognize)
|
||||
r.Post("/ui/downloads/{id}/type", s.handleSetType)
|
||||
r.Post("/ui/downloads/{id}/ignore", s.handleIgnore)
|
||||
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
|
||||
@@ -127,7 +128,7 @@ type downloadView struct {
|
||||
Terminal bool
|
||||
Reviewable bool // review/deferred — есть экран ревью
|
||||
Undoable bool // done — можно откатить раскладку
|
||||
Relinkable bool // reverted — можно перепривязать заново
|
||||
Relinkable bool // reverted/cancelled — можно перепривязать заново
|
||||
}
|
||||
|
||||
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -306,7 +307,7 @@ func toView(d store.Download) downloadView {
|
||||
Terminal: d.State.IsTerminal(),
|
||||
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
||||
Undoable: d.State == store.StateDone,
|
||||
Relinkable: d.State == store.StateReverted,
|
||||
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@ type fakeReviewer struct {
|
||||
deferred []int64
|
||||
undone []int64
|
||||
relinked []int64
|
||||
rerecognized []int64
|
||||
cleared []int64
|
||||
}
|
||||
|
||||
@@ -240,6 +241,10 @@ func (f *fakeReviewer) Relink(_ context.Context, id int64) error {
|
||||
f.relinked = append(f.relinked, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Rerecognize(_ context.Context, id int64) error {
|
||||
f.rerecognized = append(f.rerecognized, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID int64) error {
|
||||
if f.chosen == nil {
|
||||
f.chosen = map[int64]int64{}
|
||||
@@ -463,3 +468,17 @@ func TestRelink(t *testing.T) {
|
||||
t.Errorf("relinked = %v, want [1]", rv.relinked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRerecognize(t *testing.T) {
|
||||
rv := &fakeReviewer{data: seriesReviewData()}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
cl := noRedirectClient()
|
||||
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/1/rerecognize", "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rv.rerecognized) != 1 || rv.rerecognized[0] != 1 {
|
||||
t.Errorf("rerecognized = %v, want [1]", rv.rerecognized)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type Reviewer interface {
|
||||
Defer(ctx context.Context, id int64) error
|
||||
Undo(ctx context.Context, id int64) error
|
||||
Relink(ctx context.Context, id int64) error
|
||||
Rerecognize(ctx context.Context, id int64) error
|
||||
ChooseCandidate(ctx context.Context, id, candidateID int64) error
|
||||
SetProviderID(ctx context.Context, id int64, provider, providerID string) error
|
||||
ClearProvider(ctx context.Context, id int64) error
|
||||
@@ -166,6 +167,12 @@ func (s *server) handleRefine(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleRerecognize(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||
return s.deps.Reviewer.Rerecognize(ctx, id)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleSetType(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||
_ = r.ParseForm()
|
||||
|
||||
@@ -36,7 +36,7 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
|
||||
"provider_hint": "строка для поиска в базе (НЕ id)",
|
||||
"files": [
|
||||
{
|
||||
"src": "путь файла РОВНО как в списке ниже",
|
||||
"src": "путь файла из списка ниже, БЕЗ размера в скобках в конце строки",
|
||||
"role": "main" | "episode" | "subtitle" | "extra" | "sample" | "ignore",
|
||||
"season": число или null,
|
||||
"episode": число или null
|
||||
@@ -50,7 +50,8 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
|
||||
- "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore".
|
||||
- Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode.
|
||||
- Для фильма ровно один основной видеофайл role "main".
|
||||
- Поле src копируй ДОСЛОВНО из списка файлов; не выдумывай и не нормализуй пути.
|
||||
- Поле src — это путь файла из списка, скопированный дословно, но БЕЗ размера
|
||||
«(…)» в конце строки; не выдумывай и не нормализуй пути.
|
||||
- Внешние субтитры — role "subtitle".`
|
||||
|
||||
const systemPrompt = `Ты распознаёшь медиа-раздачи для медиатеки Jellyfin: по имени торрента,
|
||||
@@ -133,14 +134,15 @@ func writeFileList(b *strings.Builder, files []File, maxFiles int) {
|
||||
}
|
||||
b.WriteString("Файлы (")
|
||||
b.WriteString(strconv.Itoa(n))
|
||||
b.WriteString(", поле src — это точные пути отсюда):\n")
|
||||
b.WriteString("). В src копируй ТОЛЬКО путь — текст после номера и до размера ")
|
||||
b.WriteString("в скобках; размер «(…)» в конце строки в src НЕ включай:\n")
|
||||
for i := 0; i < shown; i++ {
|
||||
b.WriteString(strconv.Itoa(i + 1))
|
||||
b.WriteString(". [")
|
||||
b.WriteString(humanSize(files[i].Size))
|
||||
b.WriteString("] ")
|
||||
b.WriteString(". ")
|
||||
b.WriteString(files[i].Path)
|
||||
b.WriteByte('\n')
|
||||
b.WriteString(" (")
|
||||
b.WriteString(humanSize(files[i].Size))
|
||||
b.WriteString(")\n")
|
||||
}
|
||||
if shown < n {
|
||||
b.WriteString("… и ещё ")
|
||||
|
||||
@@ -286,11 +286,11 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
||||
return nil
|
||||
}
|
||||
|
||||
// Relink повторно привязывает откатанную задачу (reverted): возвращает её на
|
||||
// распознавание, и поллинг-цикл перезапустит recognize. Авто-раскладку при
|
||||
// этом не делаем — ручная перепривязка всегда проходит через ревью с
|
||||
// подтверждением (force_review). Источник (раздача в qBittorrent) для этого
|
||||
// должен быть на месте.
|
||||
// Relink повторно привязывает откатанную (reverted) или отклонённую
|
||||
// (cancelled) задачу: возвращает её на распознавание, и поллинг-цикл
|
||||
// перезапустит recognize. Авто-раскладку при этом не делаем — ручная
|
||||
// перепривязка всегда проходит через ревью с подтверждением (force_review).
|
||||
// Источник (раздача в qBittorrent) для этого должен быть на месте.
|
||||
func (w *Worker) Relink(ctx context.Context, id int64) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -299,8 +299,8 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
if d.State != store.StateReverted {
|
||||
return fmt.Errorf("relink: download %d is in state %s (expected reverted)", id, d.State)
|
||||
if d.State != store.StateReverted && d.State != store.StateCancelled {
|
||||
return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled)", id, d.State)
|
||||
}
|
||||
if !d.Infohash.Valid {
|
||||
return fmt.Errorf("relink: download %d has no infohash", id)
|
||||
@@ -325,7 +325,23 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||
w.log.Info("relink: re-recognizing reverted download", "download_id", id)
|
||||
w.log.Info("relink: re-recognizing download", "download_id", id, "from", d.State)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rerecognize перезапускает распознавание для задачи в review/deferred без
|
||||
// добавления подсказки: контекст и прежние подсказки уже накоплены. Поллинг-
|
||||
// цикл проведёт задачу recognizing → review заново.
|
||||
func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
d, err := w.requireReviewable(ctx, id, "rerecognize")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.log.Info("review: re-recognizing without hint", "download_id", id)
|
||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -120,14 +120,58 @@ func TestRelink_RevertedToRecognizing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelink_RejectsNonReverted(t *testing.T) {
|
||||
func TestRelink_CancelledToRecognizing(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1)) // не reverted
|
||||
d := revertedDownload(1)
|
||||
d.State = store.StateCancelled
|
||||
st.put(d)
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
|
||||
|
||||
if err := w.Relink(context.Background(), 1); err != nil {
|
||||
t.Fatalf("Relink: %v", err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateRecognizing {
|
||||
t.Fatalf("state = %q, want recognizing", st.downloads[1].State)
|
||||
}
|
||||
if st.overrides[1][ovrForceReview] != "1" {
|
||||
t.Errorf("force_review override = %q, want 1", st.overrides[1][ovrForceReview])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelink_RejectsActiveState(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1)) // не reverted/cancelled
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
|
||||
|
||||
if err := w.Relink(context.Background(), 1); err == nil {
|
||||
t.Fatal("ожидали ошибку для не-reverted задачи, получили nil")
|
||||
t.Fatal("ожидали ошибку для не-reverted/cancelled задачи, получили nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRerecognize_ReviewToRecognizing(t *testing.T) {
|
||||
st := newMemStore()
|
||||
d := completedDownload(1)
|
||||
d.State = store.StateReview
|
||||
st.put(d)
|
||||
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
|
||||
|
||||
if err := w.Rerecognize(context.Background(), 1); err != nil {
|
||||
t.Fatalf("Rerecognize: %v", err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateRecognizing {
|
||||
t.Fatalf("state = %q, want recognizing", st.downloads[1].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRerecognize_RejectsNonReview(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1)) // completed, не review/deferred
|
||||
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
|
||||
|
||||
if err := w.Rerecognize(context.Background(), 1); err == nil {
|
||||
t.Fatal("ожидали ошибку для не-review задачи, получили nil")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,10 @@
|
||||
<p><textarea name="hint" rows="2" placeholder="подсказка для распознавания, напр. «это второй сезон, рус+англ дорожки»"></textarea></p>
|
||||
<button type="submit">🔁 Уточнить</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/rerecognize" style="margin-top:.4rem">
|
||||
<button type="submit">🔄 Распознать заново</button>
|
||||
<small>(без новой подсказки — по уже накопленному контексту)</small>
|
||||
</form>
|
||||
{{if .Hints}}
|
||||
<p><small>Подсказки: {{range $i, $h := .Hints}}{{if $i}} · {{end}}«{{$h}}»{{end}}</small></p>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user