- модель адресует файл номером строки нашего списка вместо копии пути: ответ на 180 файлов вместо ~15k токенов стоит ~2.5k, усечение сотней снято, max_files и max_tokens ушли в [recognition], correction-ретрай больше не переприсылает список - негодный элемент ответа отбрасывается поимённой причиной, обрыв генерации и отказ по размеру запроса названы своими причинами, покрытие плана блокирует авто только при непокрытом видеофайле - раскладка показывает все файлы раздачи со строками «не в плане» и полным порядком сортировки; снимок списка файлов лёг рядом с планом (миграция 0012)
194 lines
8.2 KiB
Go
194 lines
8.2 KiB
Go
package httpapi
|
||
|
||
import (
|
||
"sort"
|
||
|
||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||
)
|
||
|
||
// fileRow — строка виджета «файл источника → раскладка», общего для экранов
|
||
// ревью и просмотра загрузки. Целевой путь берётся из превью (единая логика
|
||
// 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 — вне плана
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
w := layoutWidget{
|
||
SourceUnknown: !known,
|
||
Planned: len(distinct),
|
||
Rows: make([]fileRow, 0, len(files)+len(plan.Files)),
|
||
}
|
||
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 — человекочитаемая роль файла раскладки.
|
||
func roleLabel(role string) string {
|
||
switch role {
|
||
case "episode":
|
||
return "эпизод"
|
||
case "main", "movie":
|
||
return "фильм"
|
||
case "subtitle":
|
||
return "субтитры"
|
||
case "extra":
|
||
return "допматериал"
|
||
case "sample":
|
||
return "семпл"
|
||
case "ignore":
|
||
return "игнор"
|
||
default:
|
||
return role
|
||
}
|
||
}
|