Files
jellybit/internal/httpapi/files.go
T
av fc9a3b4066 распознавание: большие раздачи размечаются целиком, файлы вне плана видны
- модель адресует файл номером строки нашего списка вместо копии пути: ответ
  на 180 файлов вместо ~15k токенов стоит ~2.5k, усечение сотней снято,
  max_files и max_tokens ушли в [recognition], correction-ретрай больше не
  переприсылает список
- негодный элемент ответа отбрасывается поимённой причиной, обрыв генерации и
  отказ по размеру запроса названы своими причинами, покрытие плана блокирует
  авто только при непокрытом видеофайле
- раскладка показывает все файлы раздачи со строками «не в плане» и полным
  порядком сортировки; снимок списка файлов лёг рядом с планом (миграция 0012)
2026-09-02 08:54:58 +03:00

194 lines
8.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}