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