распознавание: большие раздачи размечаются целиком, файлы вне плана видны

- модель адресует файл номером строки нашего списка вместо копии пути: ответ
  на 180 файлов вместо ~15k токенов стоит ~2.5k, усечение сотней снято,
  max_files и max_tokens ушли в [recognition], correction-ретрай больше не
  переприсылает список
- негодный элемент ответа отбрасывается поимённой причиной, обрыв генерации и
  отказ по размеру запроса названы своими причинами, покрытие плана блокирует
  авто только при непокрытом видеофайле
- раскладка показывает все файлы раздачи со строками «не в плане» и полным
  порядком сортировки; снимок списка файлов лёг рядом с планом (миграция 0012)
This commit is contained in:
av
2026-09-02 08:54:58 +03:00
parent 01ac0430a9
commit fc9a3b4066
26 changed files with 1978 additions and 201 deletions
+195
View File
@@ -0,0 +1,195 @@
package httpapi
import (
"net/http"
"strings"
"testing"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
func ip(n int) *int { return &n }
// srcFiles — снимок списка файлов раздачи в порядке нумерации распознавания.
func srcFiles(paths ...string) []recognize.File {
out := make([]recognize.File, len(paths))
for i, p := range paths {
out[i] = recognize.File{Path: p, Size: 1 << 20}
}
return out
}
// Файл, которого модель не разметила, виден отдельной строкой без цели.
func TestBuildFileRows_UnplannedFileVisible(t *testing.T) {
files := srcFiles("s1/e01.mkv", "s1/e02.mkv", "extras/screen.jpg")
plan := recognize.Plan{Type: recognize.MediaSeries, Files: []recognize.PlanFile{
{Src: "s1/e01.mkv", Role: recognize.RoleEpisode, Season: ip(1), Episode: ip(1)},
}}
preview := []layout.Link{{Src: "s1/e01.mkv", Dst: "/srv/series/Show/S01/E01.mkv"}}
w := buildFileRows(plan, preview, files, true)
if len(w.Rows) != 3 {
t.Fatalf("строк %d, ожидалось по одной на файл раздачи: %+v", len(w.Rows), w.Rows)
}
if w.Planned != 1 || w.Total != 3 || w.SourceUnknown {
t.Errorf("сводка = %+v", w)
}
bySrc := map[string]fileRow{}
for _, r := range w.Rows {
bySrc[r.Src] = r
}
for _, src := range []string{"s1/e02.mkv", "extras/screen.jpg"} {
r := bySrc[src]
if !r.Unplanned || r.Dst != "" || r.Linked {
t.Errorf("%s: ожидалась строка «не в плане» без цели, получено %+v", src, r)
}
if r.RoleLabel != "не в плане" {
t.Errorf("%s: роль = %q", src, r.RoleLabel)
}
}
// Номер строки — номер адресации: позиция в списке распознавания.
if bySrc["s1/e02.mkv"].Num != 2 || bySrc["extras/screen.jpg"].Num != 3 {
t.Errorf("номера не совпадают с нумерацией распознавания: %+v", w.Rows)
}
}
// Порядок строк не зависит от порядка plan.Files.
func TestBuildFileRows_OrderIndependentOfPlan(t *testing.T) {
files := srcFiles(
"pack/s1e01.mkv", "pack/s1e02.mkv", "pack/s2e01.mkv",
"pack/sample.mkv", "pack/notes.nfo")
ep := func(src string, s, e int) recognize.PlanFile {
return recognize.PlanFile{Src: src, Role: recognize.RoleEpisode, Season: ip(s), Episode: ip(e)}
}
preview := []layout.Link{
{Src: "pack/s1e01.mkv", Dst: "/srv/Show/S01/E01.mkv"},
{Src: "pack/s1e02.mkv", Dst: "/srv/Show/S01/E02.mkv"},
{Src: "pack/s2e01.mkv", Dst: "/srv/Show/S02/E01.mkv"},
}
want := []string{"pack/s1e01.mkv", "pack/s1e02.mkv", "pack/s2e01.mkv",
"pack/sample.mkv", "pack/notes.nfo"}
orders := [][]recognize.PlanFile{
{ep("pack/s1e01.mkv", 1, 1), ep("pack/s1e02.mkv", 1, 2), ep("pack/s2e01.mkv", 2, 1),
{Src: "pack/sample.mkv", Role: recognize.RoleSample}},
{{Src: "pack/sample.mkv", Role: recognize.RoleSample}, ep("pack/s2e01.mkv", 2, 1),
ep("pack/s1e02.mkv", 1, 2), ep("pack/s1e01.mkv", 1, 1)},
}
for _, pf := range orders {
w := buildFileRows(recognize.Plan{Type: recognize.MediaSeries, Files: pf}, preview, files, true)
got := make([]string, 0, len(w.Rows))
for _, r := range w.Rows {
got = append(got, r.Src)
}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Errorf("порядок = %v, want %v", got, want)
}
}
}
// Файлы без сезона и серии не поднимаются в начало списка.
func TestBuildFileRows_UnnumberedNotFirst(t *testing.T) {
files := srcFiles("a_sample.mkv", "z_s01e01.mkv")
plan := recognize.Plan{Type: recognize.MediaSeries, Files: []recognize.PlanFile{
{Src: "a_sample.mkv", Role: recognize.RoleSample},
{Src: "z_s01e01.mkv", Role: recognize.RoleEpisode, Season: ip(1), Episode: ip(1)},
}}
preview := []layout.Link{{Src: "z_s01e01.mkv", Dst: "/srv/Show/S01/E01.mkv"}}
w := buildFileRows(plan, preview, files, true)
if w.Rows[0].Src != "z_s01e01.mkv" {
t.Errorf("первым обязан идти разложенный файл, получено %+v", w.Rows)
}
}
// Запись без сохранённого списка файлов: строки по плану + честная пометка.
func TestBuildFileRows_SourceUnknown(t *testing.T) {
plan := recognize.Plan{Type: recognize.MediaMovie, Files: []recognize.PlanFile{
{Src: "film.mkv", Role: recognize.RoleMain},
}}
w := buildFileRows(plan, []layout.Link{{Src: "film.mkv", Dst: "/srv/movies/F/f.mkv"}}, nil, false)
if !w.SourceUnknown {
t.Error("отсутствие снимка обязано быть названо явно")
}
if len(w.Rows) != 1 || w.Rows[0].Src != "film.mkv" {
t.Errorf("rows = %+v", w.Rows)
}
if w.Rows[0].Num != 0 {
t.Errorf("без снимка номер адресации неизвестен и показываться не должен: %+v", w.Rows[0])
}
}
// Страница загрузки говорит о недоступности списка файлов, а не показывает
// план как полный перечень.
func TestDownloadPage_SaysFileListUnavailable(t *testing.T) {
dl := store.Download{ID: testULID, SourceRef: "Film", State: store.StateReview}
plan := recognize.Plan{Type: recognize.MediaMovie, Title: "Film",
Files: []recognize.PlanFile{{Src: "film.mkv", Role: recognize.RoleMain}}}
rd := &worker.ReviewData{
Download: dl,
Recognition: &store.Recognition{ID: testULID, DownloadID: testULID},
Plan: plan,
}
h := testRouter(t, stubReader{list: []store.Download{dl}, one: &dl}, stubReviewer{data: rd})
rr := get(t, h, "/download/"+testULID)
if rr.Code != http.StatusOK {
t.Fatalf("GET /download/{id} = %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "Список файлов раздачи не сохранён") {
t.Errorf("страница молчит о неполноте перечня")
}
// Со снимком — пометка исчезает, а файл вне плана виден.
rd.SourceFiles = srcFiles("film.mkv", "screens/01.jpg")
rd.SourceFilesKnown = true
rr = get(t, h, "/download/"+testULID)
body := rr.Body.String()
if strings.Contains(body, "Список файлов раздачи не сохранён") {
t.Errorf("со снимком пометка о неполноте не нужна")
}
if !strings.Contains(body, "screens/01.jpg") || !strings.Contains(body, "не в плане") {
t.Errorf("файл вне плана не показан:\n%s", body)
}
}
// Легаси-план вправе разметить один файл двумя элементами (сдвоенный эпизод):
// Apply создаст два хардлинка, и виджет обязан показать обе цели — иначе
// предпросмотр перестаёт быть равен применению.
func TestBuildFileRows_DuplicateSrcShownTwice(t *testing.T) {
files := srcFiles("pack/s01e01e02.mkv", "pack/s01e03.mkv")
plan := recognize.Plan{Type: recognize.MediaSeries, Files: []recognize.PlanFile{
{Src: "pack/s01e01e02.mkv", Role: recognize.RoleEpisode, Season: ip(1), Episode: ip(1)},
{Src: "pack/s01e01e02.mkv", Role: recognize.RoleEpisode, Season: ip(1), Episode: ip(2)},
{Src: "pack/s01e03.mkv", Role: recognize.RoleEpisode, Season: ip(1), Episode: ip(3)},
}}
preview := []layout.Link{
{Src: "pack/s01e01e02.mkv", Dst: "/srv/Show/S01/E01.mkv"},
{Src: "pack/s01e01e02.mkv", Dst: "/srv/Show/S01/E02.mkv"},
{Src: "pack/s01e03.mkv", Dst: "/srv/Show/S01/E03.mkv"},
}
w := buildFileRows(plan, preview, files, true)
if len(w.Rows) != 3 {
t.Fatalf("строк %d, ожидалась строка на каждый элемент плана: %+v", len(w.Rows), w.Rows)
}
var dsts []string
for _, r := range w.Rows {
if r.Src == "pack/s01e01e02.mkv" {
dsts = append(dsts, r.Dst)
}
if r.Num == 0 {
t.Errorf("номер адресации потерян: %+v", r)
}
}
if len(dsts) != 2 || dsts[0] != "/srv/Show/S01/E01.mkv" || dsts[1] != "/srv/Show/S01/E02.mkv" {
t.Errorf("обе цели сдвоенного эпизода = %v", dsts)
}
// Знаменатель покрытия — файлы раздачи, а не элементы плана.
if w.Planned != 2 || w.Total != 2 {
t.Errorf("сводка = %+v", w)
}
}