распознавание: большие раздачи размечаются целиком, файлы вне плана видны
- модель адресует файл номером строки нашего списка вместо копии пути: ответ на 180 файлов вместо ~15k токенов стоит ~2.5k, усечение сотней снято, max_files и max_tokens ушли в [recognition], correction-ретрай больше не переприсылает список - негодный элемент ответа отбрасывается поимённой причиной, обрыв генерации и отказ по размеру запроса названы своими причинами, покрытие плана блокирует авто только при непокрытом видеофайле - раскладка показывает все файлы раздачи со строками «не в плане» и полным порядком сортировки; снимок списка файлов лёг рядом с планом (миграция 0012)
This commit is contained in:
@@ -45,7 +45,7 @@ type downloadDetailView struct {
|
||||
MatchURL string // ссылка на запись метабазы (пусто — показываем текстом)
|
||||
NoBase bool
|
||||
Confidence string
|
||||
Files []fileRow
|
||||
Files layoutWidget
|
||||
|
||||
// Живая статистика раздачи (заполняется из снимка воркера).
|
||||
Seeding seedingView
|
||||
@@ -178,7 +178,7 @@ func (s *server) buildDownloadView(id string, rd *worker.ReviewData) downloadDet
|
||||
}
|
||||
|
||||
// Файл источника → целевой путь из превью (единая логика layout).
|
||||
view.Files = buildFileRows(rd.Plan, rd.Preview)
|
||||
view.Files = buildFileRows(rd.Plan, rd.Preview, rd.SourceFiles, rd.SourceFilesKnown)
|
||||
}
|
||||
|
||||
// Живая статистика раздачи — со значениями уже в первом кадре; секция
|
||||
|
||||
+152
-16
@@ -1,6 +1,8 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||
)
|
||||
@@ -9,31 +11,165 @@ import (
|
||||
// ревью и просмотра загрузки. Целевой путь берётся из превью (единая логика
|
||||
// internal/layout), а не вычисляется в шаблоне.
|
||||
type fileRow struct {
|
||||
// Num — номер файла в списке распознавания: тот самый, которым его
|
||||
// адресует ответ модели. 0 — номер неизвестен (снимка списка нет), и тогда
|
||||
// он не показывается вовсе: расхождение сделало бы разбор ошибок адресации
|
||||
// ложным.
|
||||
Num int
|
||||
Src string
|
||||
Dst string // целевой путь; пусто — файл не раскладывается
|
||||
RoleLabel string
|
||||
Linked bool
|
||||
Ignored bool
|
||||
// Unplanned — файла нет в плане распознавания: модель его не разметила.
|
||||
// Молчаливый пропуск файла моделью не должен быть невидимым.
|
||||
Unplanned bool
|
||||
|
||||
season int // ключи сортировки; 0 у файлов без нумерации
|
||||
episode int
|
||||
group int // 0 — разложенные, 1 — план без цели, 2 — вне плана
|
||||
}
|
||||
|
||||
// buildFileRows сшивает файлы плана с целевыми путями из превью раскладки.
|
||||
func buildFileRows(plan recognize.Plan, preview []layout.Link) []fileRow {
|
||||
dstBySrc := make(map[string]string, len(preview))
|
||||
for _, l := range preview {
|
||||
dstBySrc[l.Src] = l.Dst
|
||||
// layoutWidget — данные виджета раскладки: строки плюс честность о полноте.
|
||||
type layoutWidget struct {
|
||||
Rows []fileRow
|
||||
// SourceUnknown — списка файлов раздачи для этой загрузки нет (запись
|
||||
// создана до того, как система стала его сохранять). Строки построены по
|
||||
// плану, и виджет обязан назвать это прямо.
|
||||
SourceUnknown bool
|
||||
Planned int // файлов в плане
|
||||
Total int // всего файлов раздачи (0, когда список неизвестен)
|
||||
}
|
||||
|
||||
// buildFileRows сшивает файлы РАЗДАЧИ с планом и целевыми путями из превью.
|
||||
// files — снимок списка файлов раздачи момента распознавания (в порядке
|
||||
// нумерации промпта); nil означает, что снимка нет: тогда строки строятся по
|
||||
// плану, а виджет сообщает о неполноте.
|
||||
func buildFileRows(plan recognize.Plan, preview []layout.Link, files []recognize.File, known bool) layoutWidget {
|
||||
dsts := planDsts(plan, preview)
|
||||
|
||||
// Строки строятся по ЭЛЕМЕНТАМ плана, а не по путям источников: план из БД
|
||||
// вправе разметить один файл двумя элементами (сдвоенный эпизод в записях,
|
||||
// сделанных до индексной адресации). Схлопывание таких элементов по пути
|
||||
// показало бы одну цель там, где Apply создаст два хардлинка, — предпросмотр
|
||||
// перестал бы быть равен применению (см. требование web-ui).
|
||||
planBySrc := make(map[string][]int, len(plan.Files))
|
||||
distinct := make(map[string]bool, len(plan.Files))
|
||||
for i, f := range plan.Files {
|
||||
planBySrc[f.Src] = append(planBySrc[f.Src], i)
|
||||
distinct[f.Src] = true
|
||||
}
|
||||
rows := make([]fileRow, 0, len(plan.Files))
|
||||
for _, f := range plan.Files {
|
||||
dst := dstBySrc[f.Src]
|
||||
rows = append(rows, fileRow{
|
||||
Src: f.Src,
|
||||
Dst: dst,
|
||||
RoleLabel: roleLabel(string(f.Role)),
|
||||
Linked: dst != "",
|
||||
Ignored: f.Role == "ignore",
|
||||
})
|
||||
|
||||
w := layoutWidget{
|
||||
SourceUnknown: !known,
|
||||
Planned: len(distinct),
|
||||
Rows: make([]fileRow, 0, len(files)+len(plan.Files)),
|
||||
}
|
||||
return rows
|
||||
if known {
|
||||
w.Total = len(files)
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(files))
|
||||
for i, f := range files {
|
||||
seen[f.Path] = true
|
||||
idxs := planBySrc[f.Path]
|
||||
if len(idxs) == 0 {
|
||||
w.Rows = append(w.Rows, newFileRow(i+1, f.Path, "", recognize.PlanFile{}, false))
|
||||
continue
|
||||
}
|
||||
for _, j := range idxs {
|
||||
w.Rows = append(w.Rows, newFileRow(i+1, f.Path, dsts[j], plan.Files[j], true))
|
||||
}
|
||||
}
|
||||
// План может ссылаться на файл, которого в снимке нет (снимка нет вовсе
|
||||
// либо раздачу переименовали после распознавания) — такую строку всё равно
|
||||
// показываем, но без номера: он ничего не адресует.
|
||||
for j, f := range plan.Files {
|
||||
if seen[f.Src] {
|
||||
continue
|
||||
}
|
||||
w.Rows = append(w.Rows, newFileRow(0, f.Src, dsts[j], f, true))
|
||||
}
|
||||
|
||||
// Порядок задан полностью и не зависит от порядка файлов в плане:
|
||||
// разложенные (сезон, серия, путь) → файлы плана без цели → файлы вне
|
||||
// плана. Пустые season/episode — указатели, и свёрнутые в ноль они
|
||||
// поставили бы семплы и скриншоты перед первым сезоном, поэтому место
|
||||
// таких файлов задаёт group, а не умолчание нумерации.
|
||||
sort.SliceStable(w.Rows, func(i, j int) bool {
|
||||
a, b := w.Rows[i], w.Rows[j]
|
||||
switch {
|
||||
case a.group != b.group:
|
||||
return a.group < b.group
|
||||
case a.group == 0 && a.season != b.season:
|
||||
return a.season < b.season
|
||||
case a.group == 0 && a.episode != b.episode:
|
||||
return a.episode < b.episode
|
||||
default:
|
||||
return a.Src < b.Src
|
||||
}
|
||||
})
|
||||
return w
|
||||
}
|
||||
|
||||
// planDsts — целевой путь КАЖДОГО элемента плана (пусто — элемент не
|
||||
// раскладывается). Карта «путь → цель» тут не годится: у двух элементов с одним
|
||||
// источником цели разные. Превью строится по этому же плану, в том же порядке и
|
||||
// только по раскладываемым ролям, поэтому элементы и ссылки сопоставляются
|
||||
// проходом в два указателя; расхождение (превью от другого плана) оставляет
|
||||
// строку без цели, но не сдвигает остальные.
|
||||
func planDsts(plan recognize.Plan, preview []layout.Link) []string {
|
||||
out := make([]string, len(plan.Files))
|
||||
k := 0
|
||||
for i, f := range plan.Files {
|
||||
if !linkableRole(f.Role) || k >= len(preview) || preview[k].Src != f.Src {
|
||||
continue
|
||||
}
|
||||
out[i] = preview[k].Dst
|
||||
k++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// linkableRole — роль, которую раскладка превращает в ссылку (зеркало
|
||||
// worker.mapRole; здесь нужна лишь для сопоставления строк с превью, и промах
|
||||
// стоит пустой цели в строке, а не неверной раскладки).
|
||||
func linkableRole(r recognize.FileRole) bool {
|
||||
switch r {
|
||||
case recognize.RoleMain, recognize.RoleEpisode, recognize.RoleSubtitle:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// newFileRow собирает строку виджета по файлу раздачи и его записи в плане.
|
||||
func newFileRow(num int, src, dst string, pf recognize.PlanFile, inPlan bool) fileRow {
|
||||
row := fileRow{
|
||||
Num: num,
|
||||
Src: src,
|
||||
Dst: dst,
|
||||
Linked: dst != "",
|
||||
Unplanned: !inPlan,
|
||||
group: 2,
|
||||
}
|
||||
if !inPlan {
|
||||
row.RoleLabel = "не в плане"
|
||||
return row
|
||||
}
|
||||
row.RoleLabel = roleLabel(string(pf.Role))
|
||||
row.Ignored = pf.Role == "ignore"
|
||||
row.group = 1
|
||||
if row.Linked {
|
||||
row.group = 0
|
||||
}
|
||||
if pf.Season != nil {
|
||||
row.season = *pf.Season
|
||||
}
|
||||
if pf.Episode != nil {
|
||||
row.episode = *pf.Episode
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
// roleLabel — человекочитаемая роль файла раскладки.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ type reviewView struct {
|
||||
Confidence string
|
||||
Reasons []string
|
||||
Hints []string
|
||||
Files []fileRow
|
||||
Files layoutWidget
|
||||
HasPlan bool
|
||||
HasLinks bool // есть хотя бы один целевой путь → можно применять
|
||||
NoBase bool // выбрано «без базы»
|
||||
@@ -145,7 +145,7 @@ func buildReviewView(id string, rd *worker.ReviewData, errMsg string) reviewView
|
||||
if rec.Confidence.Valid {
|
||||
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
||||
}
|
||||
view.Files = buildFileRows(rd.Plan, rd.Preview)
|
||||
view.Files = buildFileRows(rd.Plan, rd.Preview, rd.SourceFiles, rd.SourceFilesKnown)
|
||||
view.HasPlan = len(rd.Plan.Files) > 0
|
||||
view.HasLinks = len(rd.Preview) > 0
|
||||
for _, src := range rd.Sources {
|
||||
|
||||
Reference in New Issue
Block a user