Files
jellybit/internal/httpapi/files.go
T
avandClaude Opus 4.8 1f4267a046 Ревью: единый блок выбора источника, клик = выбор (review-unified-source-block)
Три секции экрана ревью (Догадка/Источник/Раскладка) слиты в один блок:
список вариантов (радио) → инфо о выбранном → предпросмотр раскладки. Клик
по варианту сразу выбирает и сохраняет источник и обновляет инфо+раскладку
частичным htmx-свопом блока, без полной перезагрузки и без кнопки «выбрать».

- httpapi: reviewBlockAction (htmx-aware, детект HX-Request) для
  candidate/nobase/source; вынос buildReviewView; поля SeasonSummary и
  BlockError; сводка сезонов (seasonSummary/seasonRanges)
- тип movie↔series убран из UI (read-only); удалён веб-роут /type и
  handleSetType, метод SetType из интерфейса httpapi (worker/Telegram не тронуты)
- шаблон: партиал review_source_block, ссылка «запись ↗» вне кликабельного
  label, фокус радио с клавиатуры; чистка мёртвого sourceView.Files/IsSeries
- тесты: htmx-своп выбора, htmx-путь ошибки, юнит-тесты сводки сезонов
- openspec: спеки review/web-ui синхронизированы, change заархивирован
- беклог: сложные сериальные раздачи; oob-обновление панели действий

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 08:12:28 +03:00

125 lines
3.5 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"
"strconv"
"strings"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/recognize"
)
// fileRow — строка виджета «файл источника → раскладка», общего для экранов
// ревью и просмотра загрузки. Целевой путь берётся из превью (единая логика
// internal/layout), а не вычисляется в шаблоне.
type fileRow struct {
Src string
Dst string // целевой путь; пусто — файл не раскладывается
RoleLabel string
Linked bool
Ignored bool
}
// 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
}
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",
})
}
return rows
}
// seasonSummary собирает верхнеуровневую сводку сезонов сериальной раздачи по
// эпизодным файлам плана. Сезон задан на файле (мультисезонные паки), поэтому
// сводим множество различных сезонов; Season nil/0 — спецвыпуски. Примеры:
// «Сезон 2», «Сезоны 1–3», «Сезоны 1, 3–4», «Спецвыпуски», «Сезоны 1–2, спецвыпуски».
func seasonSummary(plan recognize.Plan) string {
seen := map[int]bool{}
specials := false
for _, f := range plan.Files {
if f.Role != recognize.RoleEpisode {
continue
}
n := 0
if f.Season != nil {
n = *f.Season
}
if n <= 0 {
specials = true
continue
}
seen[n] = true
}
nums := make([]int, 0, len(seen))
for n := range seen {
nums = append(nums, n)
}
sort.Ints(nums)
var parts []string
switch {
case len(nums) == 1:
parts = append(parts, "Сезон "+strconv.Itoa(nums[0]))
case len(nums) > 1:
parts = append(parts, "Сезоны "+seasonRanges(nums))
}
if specials {
if len(parts) == 0 {
parts = append(parts, "Спецвыпуски")
} else {
parts = append(parts, "спецвыпуски")
}
}
return strings.Join(parts, ", ")
}
// seasonRanges схлопывает возрастающие номера сезонов в диапазоны:
// [1,2,3] → «13», [1,3,4] → «1, 34».
func seasonRanges(nums []int) string {
var out []string
for i := 0; i < len(nums); {
j := i
for j+1 < len(nums) && nums[j+1] == nums[j]+1 {
j++
}
if j == i {
out = append(out, strconv.Itoa(nums[i]))
} else {
out = append(out, strconv.Itoa(nums[i])+""+strconv.Itoa(nums[j]))
}
i = j + 1
}
return strings.Join(out, ", ")
}
// 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
}
}