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 } }