UI/UX списка и карточки загрузки: серверные фильтр/поиск/пагинация, матч-ссылка, имя раздачи (web-ui-list-detail)
- Список: серверные фильтр по группе состояний, поиск и пагинация (GET
f/q/page/all, по 25), сортировка по времени добавления в qBittorrent
(added_on) с фолбеком на created_at и tie-break по id.
- Заголовок загрузки = имя раздачи (display_name) → распознанное название →
усечённый источник; сырой magnet вынесен в блок «Информация о торренте».
- Матч метабазы показан ссылкой на запись (страница загрузки и ревью);
URL берётся у выбранного кандидата либо строится по provider+id и типу.
- Полировка вёрстки; клиентская JS-фильтрация убрана (всё серверное, без JS).
- Миграция 0005 (display_name, source_added_at); воркер однократно
фиксирует source_added_at при поллинге/усыновлении; ER-схема обновлена.
- OpenSpec: дельты влиты в specs/{web-ui,ingest}, change заархивирован.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
> документации.
|
> документации.
|
||||||
>
|
>
|
||||||
> Состояние на: миграции `0001_init`, `0002_recognition_plan`,
|
> Состояние на: миграции `0001_init`, `0002_recognition_plan`,
|
||||||
> `0003_source_miss_count`.
|
> `0003_source_miss_count`, `0004_candidate_url`, `0005_display_name`.
|
||||||
|
|
||||||
Назначение таблиц и почему так — [architecture.md](architecture.md) →
|
Назначение таблиц и почему так — [architecture.md](architecture.md) →
|
||||||
«Хранилище». Значения `state` и переходы — [workflow.md](workflow.md).
|
«Хранилище». Значения `state` и переходы — [workflow.md](workflow.md).
|
||||||
@@ -29,6 +29,7 @@ erDiagram
|
|||||||
INTEGER id PK "AUTOINCREMENT"
|
INTEGER id PK "AUTOINCREMENT"
|
||||||
TEXT source_type "NOT NULL; magnet|torrent|url"
|
TEXT source_type "NOT NULL; magnet|torrent|url"
|
||||||
TEXT source_ref "NOT NULL; magnet/url/путь"
|
TEXT source_ref "NOT NULL; magnet/url/путь"
|
||||||
|
TEXT display_name "NOT NULL DEFAULT ''; имя раздачи (rename qBittorrent), заголовок в UI (миграция 0005)"
|
||||||
TEXT context "NOT NULL DEFAULT ''"
|
TEXT context "NOT NULL DEFAULT ''"
|
||||||
TEXT infohash "nullable; может появиться позже приёма"
|
TEXT infohash "nullable; может появиться позже приёма"
|
||||||
TEXT idempotency_key "nullable; UNIQUE если NOT NULL"
|
TEXT idempotency_key "nullable; UNIQUE если NOT NULL"
|
||||||
@@ -36,6 +37,7 @@ erDiagram
|
|||||||
TEXT error_code "nullable"
|
TEXT error_code "nullable"
|
||||||
TEXT error_msg "nullable"
|
TEXT error_msg "nullable"
|
||||||
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
|
INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)"
|
||||||
|
TEXT source_added_at "nullable; время добавления в qBittorrent (added_on), базис сортировки (миграция 0005)"
|
||||||
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
TEXT created_at "NOT NULL DEFAULT datetime('now')"
|
||||||
TEXT updated_at "NOT NULL DEFAULT datetime('now')"
|
TEXT updated_at "NOT NULL DEFAULT datetime('now')"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Страница просмотра одной загрузки ---
|
// --- Страница просмотра одной загрузки ---
|
||||||
@@ -13,7 +14,8 @@ import (
|
|||||||
type downloadDetailView struct {
|
type downloadDetailView struct {
|
||||||
ID int64
|
ID int64
|
||||||
Title string
|
Title string
|
||||||
Source string
|
SourceType string // тип источника (magnet/torrent/url) — блок «Информация о торренте»
|
||||||
|
SourceFull string // полный источник (magnet) — блок «Информация о торренте»
|
||||||
Infohash string
|
Infohash string
|
||||||
InfohashShort string
|
InfohashShort string
|
||||||
Context string
|
Context string
|
||||||
@@ -32,6 +34,7 @@ type downloadDetailView struct {
|
|||||||
Year int
|
Year int
|
||||||
Provider string
|
Provider string
|
||||||
ProviderID string
|
ProviderID string
|
||||||
|
MatchURL string // ссылка на запись метабазы (пусто — показываем текстом)
|
||||||
NoBase bool
|
NoBase bool
|
||||||
Confidence string
|
Confidence string
|
||||||
Files []fileRow
|
Files []fileRow
|
||||||
@@ -47,6 +50,18 @@ type downloadDetailView struct {
|
|||||||
Retriable bool
|
Retriable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// detailTitle — заголовок страницы просмотра: имя раздачи (display_name) →
|
||||||
|
// распознанное название (план) → усечённый до одной строки сырой источник.
|
||||||
|
func detailTitle(d store.Download, rd *worker.ReviewData) string {
|
||||||
|
if d.DisplayName != "" {
|
||||||
|
return d.DisplayName
|
||||||
|
}
|
||||||
|
if rd.Plan.Title != "" {
|
||||||
|
return rd.Plan.Title
|
||||||
|
}
|
||||||
|
return shorten(oneLine(d.SourceRef), 120)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := pathID(r)
|
id, err := pathID(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -67,8 +82,9 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
d := rd.Download
|
d := rd.Download
|
||||||
view := downloadDetailView{
|
view := downloadDetailView{
|
||||||
ID: id,
|
ID: id,
|
||||||
Title: d.SourceRef,
|
Title: detailTitle(d, rd),
|
||||||
Source: shorten(d.SourceRef, 120),
|
SourceType: string(d.SourceType),
|
||||||
|
SourceFull: d.SourceRef,
|
||||||
Infohash: d.Infohash.String,
|
Infohash: d.Infohash.String,
|
||||||
InfohashShort: shortenHash(d.Infohash.String),
|
InfohashShort: shortenHash(d.Infohash.String),
|
||||||
Context: d.Context,
|
Context: d.Context,
|
||||||
@@ -98,6 +114,7 @@ func (s *server) handleDownload(w http.ResponseWriter, r *http.Request) {
|
|||||||
default:
|
default:
|
||||||
view.Provider = rd.Provider
|
view.Provider = rd.Provider
|
||||||
view.ProviderID = rd.ProviderID
|
view.ProviderID = rd.ProviderID
|
||||||
|
view.MatchURL = matchURL(rd, view.MediaType)
|
||||||
}
|
}
|
||||||
if rd.Recognition.Confidence.Valid {
|
if rd.Recognition.Confidence.Valid {
|
||||||
view.Confidence = strconv.FormatFloat(rd.Recognition.Confidence.Float64, 'f', 2, 64)
|
view.Confidence = strconv.FormatFloat(rd.Recognition.Confidence.Float64, 'f', 2, 64)
|
||||||
|
|||||||
+189
-11
@@ -42,6 +42,7 @@ type Commander interface {
|
|||||||
// Reader читает задачи (store.Store).
|
// Reader читает задачи (store.Store).
|
||||||
type Reader interface {
|
type Reader interface {
|
||||||
ListDownloads(ctx context.Context) ([]store.Download, error)
|
ListDownloads(ctx context.Context) ([]store.Download, error)
|
||||||
|
ListDownloadsPage(ctx context.Context, f store.ListFilter) ([]store.Download, int, error)
|
||||||
GetDownload(ctx context.Context, id int64) (*store.Download, error)
|
GetDownload(ctx context.Context, id int64) (*store.Download, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,26 +140,54 @@ func handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
|||||||
|
|
||||||
// --- Веб-UI ---
|
// --- Веб-UI ---
|
||||||
|
|
||||||
|
// pageSize — размер страницы списка загрузок (серверная пагинация).
|
||||||
|
const pageSize = 25
|
||||||
|
|
||||||
type indexView struct {
|
type indexView struct {
|
||||||
Error string
|
Error string
|
||||||
Downloads []downloadView
|
Downloads []downloadView
|
||||||
|
|
||||||
|
// Фильтр/поиск (серверные, в query).
|
||||||
|
Filter string // активная группа (all/review/active/done/problem)
|
||||||
|
Query string // текст поиска
|
||||||
|
ShowAll bool // показывать удалённые
|
||||||
|
Chips []filterChip // чипы фильтра со ссылками
|
||||||
|
ShowURL string // ссылка тумблера «показать всё»
|
||||||
|
|
||||||
|
// Пагинация.
|
||||||
|
Page int
|
||||||
|
Pages int // всего страниц (>=1)
|
||||||
|
Total int // всего строк под фильтром
|
||||||
|
Searching bool // активны фильтр/поиск — влияет на текст пустого состояния
|
||||||
|
PrevURL string // пусто — на первой странице
|
||||||
|
NextURL string // пусто — на последней
|
||||||
|
PageLinks []pageLink // пронумерованные страницы (окно)
|
||||||
|
}
|
||||||
|
|
||||||
|
type filterChip struct {
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
URL string
|
||||||
|
Active bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type pageLink struct {
|
||||||
|
Num int
|
||||||
|
URL string
|
||||||
|
Active bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type downloadView struct {
|
type downloadView struct {
|
||||||
ID int64
|
ID int64
|
||||||
Title string // отображаемый заголовок карточки
|
Title string // отображаемый заголовок карточки
|
||||||
Source string
|
|
||||||
Infohash string // полный (для копирования)
|
Infohash string // полный (для копирования)
|
||||||
InfohashShort string // усечённый (для показа)
|
InfohashShort string // усечённый (для показа)
|
||||||
Context string
|
Context string
|
||||||
State string
|
State string
|
||||||
Group string // группа фильтра (review/active/done/problem/other)
|
|
||||||
SearchText string // haystack для клиентского поиска (lowercase)
|
|
||||||
Error string
|
Error string
|
||||||
Terminal bool
|
Terminal bool
|
||||||
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг
|
IsDownloading bool // активная загрузка → живой прогресс-бар + поллинг
|
||||||
Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
|
Progress progressView // живой прогресс (заполняется в handleIndex из снимка)
|
||||||
Deleted bool // скрыт по умолчанию на главной
|
|
||||||
Reviewable bool // review/deferred — есть экран ревью
|
Reviewable bool // review/deferred — есть экран ревью
|
||||||
Undoable bool // done — можно откатить раскладку
|
Undoable bool // done — можно откатить раскладку
|
||||||
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
|
Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново
|
||||||
@@ -166,14 +195,75 @@ type downloadView struct {
|
|||||||
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
|
Note string // пояснение рассинхрона (target_missing/orphaned/deleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listChips — определения чипов фильтра списка (порядок = порядок показа).
|
||||||
|
var listChips = []struct {
|
||||||
|
Key string
|
||||||
|
Label string
|
||||||
|
Group store.StateGroup
|
||||||
|
}{
|
||||||
|
{"all", "Все", store.GroupAll},
|
||||||
|
{"review", "Ждут меня", store.GroupReview},
|
||||||
|
{"active", "В работе", store.GroupActive},
|
||||||
|
{"done", "Готово", store.GroupDone},
|
||||||
|
{"problem", "Проблемы", store.GroupProblem},
|
||||||
|
}
|
||||||
|
|
||||||
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
downloads, err := s.deps.Reader.ListDownloads(r.Context())
|
q := r.URL.Query()
|
||||||
|
group := parseGroup(q.Get("f"))
|
||||||
|
query := strings.TrimSpace(q.Get("q"))
|
||||||
|
showAll := q.Get("all") == "1"
|
||||||
|
page := parsePage(q.Get("page"))
|
||||||
|
|
||||||
|
downloads, total, err := s.deps.Reader.ListDownloadsPage(r.Context(), store.ListFilter{
|
||||||
|
Group: group,
|
||||||
|
Query: query,
|
||||||
|
IncludeDeleted: showAll,
|
||||||
|
Limit: pageSize,
|
||||||
|
Offset: (page - 1) * pageSize,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.deps.Logger.Error("list downloads", "error", err)
|
s.deps.Logger.Error("list downloads", "error", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
view := indexView{Error: r.URL.Query().Get("err")}
|
|
||||||
|
pages := max((total+pageSize-1)/pageSize, 1)
|
||||||
|
|
||||||
|
view := indexView{
|
||||||
|
Error: q.Get("err"),
|
||||||
|
Filter: string(group),
|
||||||
|
Query: query,
|
||||||
|
ShowAll: showAll,
|
||||||
|
Page: page,
|
||||||
|
Pages: pages,
|
||||||
|
Total: total,
|
||||||
|
Searching: query != "" || group != store.GroupAll || showAll || page > 1,
|
||||||
|
}
|
||||||
|
// Чипы: сохраняют q/all, сбрасывают страницу.
|
||||||
|
for _, c := range listChips {
|
||||||
|
view.Chips = append(view.Chips, filterChip{
|
||||||
|
Key: c.Key,
|
||||||
|
Label: c.Label,
|
||||||
|
URL: listURL(c.Group, query, showAll, 1),
|
||||||
|
Active: c.Group == group,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// Тумблер «показать всё» переключает all, сохраняя фильтр/поиск.
|
||||||
|
view.ShowURL = listURL(group, query, !showAll, 1)
|
||||||
|
// Пагинация: сохраняет f/q/all.
|
||||||
|
if page > 1 {
|
||||||
|
view.PrevURL = listURL(group, query, showAll, page-1)
|
||||||
|
}
|
||||||
|
if page < pages {
|
||||||
|
view.NextURL = listURL(group, query, showAll, page+1)
|
||||||
|
}
|
||||||
|
for _, n := range pageWindow(page, pages) {
|
||||||
|
view.PageLinks = append(view.PageLinks, pageLink{
|
||||||
|
Num: n, URL: listURL(group, query, showAll, n), Active: n == page,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
for _, d := range downloads {
|
for _, d := range downloads {
|
||||||
v := toView(d)
|
v := toView(d)
|
||||||
// Живой прогресс активных загрузок — со значениями уже в первом кадре
|
// Живой прогресс активных загрузок — со значениями уже в первом кадре
|
||||||
@@ -187,6 +277,78 @@ func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.render(w, "index.html", view)
|
s.render(w, "index.html", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseGroup разбирает параметр фильтра `f`; неизвестное → all.
|
||||||
|
func parseGroup(s string) store.StateGroup {
|
||||||
|
switch store.StateGroup(s) {
|
||||||
|
case store.GroupReview:
|
||||||
|
return store.GroupReview
|
||||||
|
case store.GroupActive:
|
||||||
|
return store.GroupActive
|
||||||
|
case store.GroupDone:
|
||||||
|
return store.GroupDone
|
||||||
|
case store.GroupProblem:
|
||||||
|
return store.GroupProblem
|
||||||
|
default:
|
||||||
|
return store.GroupAll
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePage разбирает номер страницы (1-based); мусор/<1 → 1. За последней
|
||||||
|
// страницей отдаём как есть — запрос вернёт пустую страницу (не ошибка).
|
||||||
|
func parsePage(s string) int {
|
||||||
|
n, err := strconv.Atoi(s)
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// listURL строит ссылку списка с сохранением состояния фильтра/поиска/страницы.
|
||||||
|
// Дефолты (all, пустой поиск, page 1) в query не пишем — URL чистый.
|
||||||
|
func listURL(group store.StateGroup, query string, showAll bool, page int) string {
|
||||||
|
v := url.Values{}
|
||||||
|
if group != store.GroupAll {
|
||||||
|
v.Set("f", string(group))
|
||||||
|
}
|
||||||
|
if query != "" {
|
||||||
|
v.Set("q", query)
|
||||||
|
}
|
||||||
|
if showAll {
|
||||||
|
v.Set("all", "1")
|
||||||
|
}
|
||||||
|
if page > 1 {
|
||||||
|
v.Set("page", strconv.Itoa(page))
|
||||||
|
}
|
||||||
|
if len(v) == 0 {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return "/?" + v.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageWindow возвращает номера страниц вокруг текущей (окно до 7), чтобы пагинация
|
||||||
|
// не разрасталась на больших списках.
|
||||||
|
func pageWindow(page, pages int) []int {
|
||||||
|
const win = 7
|
||||||
|
if pages <= win {
|
||||||
|
out := make([]int, pages)
|
||||||
|
for i := range out {
|
||||||
|
out[i] = i + 1
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
start := max(page-win/2, 1)
|
||||||
|
end := start + win - 1
|
||||||
|
if end > pages {
|
||||||
|
end = pages
|
||||||
|
start = end - win + 1
|
||||||
|
}
|
||||||
|
out := make([]int, 0, win)
|
||||||
|
for n := start; n <= end; n++ {
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
|
func (s *server) handleUIAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
redirectErr(w, r, "не удалось разобрать форму")
|
redirectErr(w, r, "не удалось разобрать форму")
|
||||||
@@ -358,18 +520,14 @@ func toView(d store.Download) downloadView {
|
|||||||
state := string(d.State)
|
state := string(d.State)
|
||||||
return downloadView{
|
return downloadView{
|
||||||
ID: d.ID,
|
ID: d.ID,
|
||||||
Title: d.SourceRef,
|
Title: downloadTitle(d),
|
||||||
Source: shorten(d.SourceRef, 64),
|
|
||||||
Infohash: d.Infohash.String,
|
Infohash: d.Infohash.String,
|
||||||
InfohashShort: shortenHash(d.Infohash.String),
|
InfohashShort: shortenHash(d.Infohash.String),
|
||||||
Context: d.Context,
|
Context: d.Context,
|
||||||
State: state,
|
State: state,
|
||||||
Group: stateGroup(state),
|
|
||||||
SearchText: strings.ToLower(d.SourceRef + " " + d.Infohash.String + " " + d.Context),
|
|
||||||
Error: d.ErrorMsg.String,
|
Error: d.ErrorMsg.String,
|
||||||
Terminal: d.State.IsTerminal(),
|
Terminal: d.State.IsTerminal(),
|
||||||
IsDownloading: d.State == store.StateDownloading,
|
IsDownloading: d.State == store.StateDownloading,
|
||||||
Deleted: d.State == store.StateDeleted,
|
|
||||||
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
||||||
Undoable: d.State == store.StateDone,
|
Undoable: d.State == store.StateDone,
|
||||||
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
|
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled ||
|
||||||
@@ -379,6 +537,26 @@ func toView(d store.Download) downloadView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// downloadTitle — заголовок загрузки для списка: имя раздачи (display_name,
|
||||||
|
// то, что ушло в qBittorrent) → распознанное название (RecTitle из листинга) →
|
||||||
|
// усечённый до одной строки сырой источник. Сырой magnet не должен занимать
|
||||||
|
// несколько строк заголовка.
|
||||||
|
func downloadTitle(d store.Download) string {
|
||||||
|
if d.DisplayName != "" {
|
||||||
|
return d.DisplayName
|
||||||
|
}
|
||||||
|
if d.RecTitle.Valid && d.RecTitle.String != "" {
|
||||||
|
return d.RecTitle.String
|
||||||
|
}
|
||||||
|
return shorten(oneLine(d.SourceRef), 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
// oneLine схлопывает переводы строк и лишние пробелы — сырой источник в
|
||||||
|
// заголовок кладём одной строкой.
|
||||||
|
func oneLine(s string) string {
|
||||||
|
return strings.Join(strings.Fields(s), " ")
|
||||||
|
}
|
||||||
|
|
||||||
// shortenHash усекает infohash до вида "a1b2c3d4e5…f0" для компактного показа.
|
// shortenHash усекает infohash до вида "a1b2c3d4e5…f0" для компактного показа.
|
||||||
func shortenHash(h string) string {
|
func shortenHash(h string) string {
|
||||||
if len(h) <= 12 {
|
if len(h) <= 12 {
|
||||||
|
|||||||
@@ -55,12 +55,27 @@ func (f *fakeCommander) Retry(_ context.Context, id int64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fakeReader struct {
|
type fakeReader struct {
|
||||||
list []store.Download
|
list []store.Download
|
||||||
get *store.Download
|
get *store.Download
|
||||||
|
pageErr error
|
||||||
|
total int // общее число под фильтром (для пагинации); 0 → len(list)
|
||||||
|
lastFilter store.ListFilter // последний переданный фильтр
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeReader) ListDownloads(_ context.Context) ([]store.Download, error) { return f.list, nil }
|
func (f *fakeReader) ListDownloads(_ context.Context) ([]store.Download, error) { return f.list, nil }
|
||||||
|
|
||||||
|
func (f *fakeReader) ListDownloadsPage(_ context.Context, flt store.ListFilter) ([]store.Download, int, error) {
|
||||||
|
f.lastFilter = flt
|
||||||
|
if f.pageErr != nil {
|
||||||
|
return nil, 0, f.pageErr
|
||||||
|
}
|
||||||
|
total := f.total
|
||||||
|
if total == 0 {
|
||||||
|
total = len(f.list)
|
||||||
|
}
|
||||||
|
return f.list, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeReader) GetDownload(_ context.Context, id int64) (*store.Download, error) {
|
func (f *fakeReader) GetDownload(_ context.Context, id int64) (*store.Download, error) {
|
||||||
if f.get != nil {
|
if f.get != nil {
|
||||||
return f.get, nil
|
return f.get, nil
|
||||||
@@ -221,6 +236,149 @@ type ingestErr string
|
|||||||
|
|
||||||
func (e ingestErr) Error() string { return string(e) }
|
func (e ingestErr) Error() string { return string(e) }
|
||||||
|
|
||||||
|
func TestIndexTitleFromDisplayName(t *testing.T) {
|
||||||
|
// Заголовок карточки — имя раздачи (display_name), а не сырой magnet.
|
||||||
|
reader := &fakeReader{list: []store.Download{{
|
||||||
|
ID: 1, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:abc",
|
||||||
|
DisplayName: "Дюна: Часть вторая (2024)", State: store.StateDownloading,
|
||||||
|
}}}
|
||||||
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if !strings.Contains(string(body), "Дюна: Часть вторая (2024)") {
|
||||||
|
t.Error("заголовок карточки не из display_name")
|
||||||
|
}
|
||||||
|
if strings.Contains(string(body), "magnet:?xt=urn:btih:abc") {
|
||||||
|
t.Error("сырой magnet просочился в список вместо имени раздачи")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexListFilterParams(t *testing.T) {
|
||||||
|
// GET-параметры списка транслируются в store.ListFilter (серверная выборка).
|
||||||
|
reader := &fakeReader{total: 100}
|
||||||
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/?f=review&q=дюна&page=2&all=1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
f := reader.lastFilter
|
||||||
|
if f.Group != store.GroupReview {
|
||||||
|
t.Errorf("Group = %q, want review", f.Group)
|
||||||
|
}
|
||||||
|
if f.Query != "дюна" {
|
||||||
|
t.Errorf("Query = %q", f.Query)
|
||||||
|
}
|
||||||
|
if !f.IncludeDeleted {
|
||||||
|
t.Errorf("IncludeDeleted = false, want true (all=1)")
|
||||||
|
}
|
||||||
|
if f.Limit != 25 || f.Offset != 25 { // page 2, размер 25
|
||||||
|
t.Errorf("Limit/Offset = %d/%d, want 25/25", f.Limit, f.Offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndexPageClamp(t *testing.T) {
|
||||||
|
reader := &fakeReader{}
|
||||||
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, Reader: reader})
|
||||||
|
resp, err := http.Get(srv.URL + "/?page=0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if reader.lastFilter.Offset != 0 {
|
||||||
|
t.Errorf("page=0 → Offset %d, want 0 (клэмп на 1)", reader.lastFilter.Offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// detailReviewData — ReviewData для страницы /download/{id} с матчем метабазы.
|
||||||
|
func detailReviewData(provider, providerID, chosenID, chosenURL string) *worker.ReviewData {
|
||||||
|
rd := &worker.ReviewData{
|
||||||
|
Download: store.Download{
|
||||||
|
ID: 1, State: store.StateReview, SourceType: store.SourceMagnet,
|
||||||
|
SourceRef: "magnet:?xt=urn:btih:deadbeef", DisplayName: "Дюна",
|
||||||
|
Infohash: store.NullString("deadbeef"),
|
||||||
|
},
|
||||||
|
Recognition: &store.Recognition{ID: 1, DownloadID: 1, IsCurrent: true},
|
||||||
|
Plan: recognize.Plan{
|
||||||
|
Type: recognize.MediaMovie, Title: "Дюна", Year: 2024,
|
||||||
|
Files: []recognize.PlanFile{{Src: "dune.mkv", Role: recognize.RoleMain}},
|
||||||
|
},
|
||||||
|
Preview: []layout.Link{{Src: "dune.mkv", Dst: "/srv/media/movies/Дюна (2024)/Дюна (2024).mkv"}},
|
||||||
|
Provider: provider,
|
||||||
|
ProviderID: providerID,
|
||||||
|
}
|
||||||
|
if chosenID != "" {
|
||||||
|
rd.Candidates = []store.MetadataCandidate{{
|
||||||
|
ID: 10, Provider: "tmdb", ProviderID: chosenID, Chosen: true,
|
||||||
|
URL: store.NullString(chosenURL),
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
return rd
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadMatchLinkFromCandidate(t *testing.T) {
|
||||||
|
// Выбранный кандидат совпадает с эффективным provider+id → ссылка кандидата.
|
||||||
|
rv := &fakeReviewer{data: detailReviewData("tmdb", "693134", "693134", "https://custom.example/x")}
|
||||||
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/download/1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if !strings.Contains(string(body), "https://custom.example/x") {
|
||||||
|
t.Error("ссылка на запись метабазы (URL кандидата) не показана")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadMatchLinkMismatchUsesBuilt(t *testing.T) {
|
||||||
|
// Эффективный id (999, ручной override) отличается от выбранного кандидата
|
||||||
|
// (693134) → ссылка строится по эффективному id, а не берётся у кандидата.
|
||||||
|
rv := &fakeReviewer{data: detailReviewData("tmdb", "999", "693134", "https://custom.example/x")}
|
||||||
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/download/1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if !strings.Contains(string(body), "https://www.themoviedb.org/movie/999") {
|
||||||
|
t.Error("ссылка не построена по эффективному provider_id (movie/999)")
|
||||||
|
}
|
||||||
|
if strings.Contains(string(body), "https://custom.example/x") {
|
||||||
|
t.Error("взят URL кандидата, не совпадающего с эффективным id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDownloadTorrentInfoBlock(t *testing.T) {
|
||||||
|
rv := &fakeReviewer{data: detailReviewData("tmdb", "693134", "693134", "https://x/y")}
|
||||||
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/download/1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
for _, want := range []string{"Информация о торренте", "magnet:?xt=urn:btih:deadbeef", "Дюна"} {
|
||||||
|
if !strings.Contains(string(body), want) {
|
||||||
|
t.Errorf("страница загрузки не содержит %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Ревью ---
|
// --- Ревью ---
|
||||||
|
|
||||||
type fakeReviewer struct {
|
type fakeReviewer struct {
|
||||||
@@ -360,6 +518,28 @@ func TestReviewRenders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReviewShowsMatchLink(t *testing.T) {
|
||||||
|
// Подтверждённый матч в блоке «Источник совпадения» — ссылкой на запись.
|
||||||
|
rd := seriesReviewData()
|
||||||
|
rd.Provider = "tvdb"
|
||||||
|
rd.ProviderID = "269613"
|
||||||
|
rd.Candidates[0].Chosen = true // tvdb 269613 в seriesReviewData
|
||||||
|
rd.Candidates[0].URL = store.NullString("https://www.thetvdb.com/dereferrer/series/269613")
|
||||||
|
rv := &fakeReviewer{data: rd}
|
||||||
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
|
Reader: &fakeReader{}, Reviewer: rv})
|
||||||
|
|
||||||
|
resp, err := http.Get(srv.URL + "/review/1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if !strings.Contains(string(body), "https://www.thetvdb.com/dereferrer/series/269613") {
|
||||||
|
t.Error("строка «Выбрано» не содержит ссылку на запись метабазы")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChooseCandidate(t *testing.T) {
|
func TestChooseCandidate(t *testing.T) {
|
||||||
rv := &fakeReviewer{data: seriesReviewData()}
|
rv := &fakeReviewer{data: seriesReviewData()}
|
||||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
|
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProviderURL(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
provider string
|
||||||
|
id string
|
||||||
|
mtype string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"tmdb movie", "tmdb", "693134", "movie", "https://www.themoviedb.org/movie/693134"},
|
||||||
|
{"tmdb series", "tmdb", "60622", "series", "https://www.themoviedb.org/tv/60622"},
|
||||||
|
{"tvdb series", "tvdb", "269613", "series", "https://www.thetvdb.com/dereferrer/series/269613"},
|
||||||
|
{"tvdb movie", "tvdb", "12345", "movie", "https://www.thetvdb.com/dereferrer/movie/12345"},
|
||||||
|
{"imdb", "imdb", "tt0111161", "movie", "https://www.imdb.com/title/tt0111161"},
|
||||||
|
{"unknown provider → пусто", "kinopoisk", "42", "movie", ""},
|
||||||
|
{"пустой id → пусто", "tmdb", "", "movie", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := providerURL(c.provider, c.id, c.mtype); got != c.want {
|
||||||
|
t.Errorf("providerURL(%q,%q,%q) = %q, want %q", c.provider, c.id, c.mtype, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMatchURLNoLinkWhenUnbuildable — эффективный провайдер, для которого URL не
|
||||||
|
// строится и совпадающего кандидата нет, даёт пустую ссылку (транспорт покажет
|
||||||
|
// матч текстом — сценарий «URL записи неизвестен»).
|
||||||
|
func TestMatchURLNoLinkWhenUnbuildable(t *testing.T) {
|
||||||
|
rd := &worker.ReviewData{Provider: "kinopoisk", ProviderID: "42"}
|
||||||
|
if got := matchURL(rd, "movie"); got != "" {
|
||||||
|
t.Errorf("matchURL = %q, want пусто (URL не строится, кандидата нет)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMatchURLPrefersMatchingCandidate — URL берётся у выбранного кандидата,
|
||||||
|
// когда его provider+id совпадают с эффективными.
|
||||||
|
func TestMatchURLPrefersMatchingCandidate(t *testing.T) {
|
||||||
|
rd := &worker.ReviewData{
|
||||||
|
Provider: "tmdb", ProviderID: "693134",
|
||||||
|
Candidates: []store.MetadataCandidate{{
|
||||||
|
Provider: "tmdb", ProviderID: "693134", Chosen: true,
|
||||||
|
URL: store.NullString("https://custom.example/x"),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if got := matchURL(rd, "movie"); got != "https://custom.example/x" {
|
||||||
|
t.Errorf("matchURL = %q, want URL выбранного кандидата", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,23 +73,6 @@ func badgeLabel(state string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// stateGroup — группа фильтра на главной (чипы): ждут меня / в работе / готово
|
|
||||||
// / проблемы / прочее. Завязано на смысл состояния, не на цвет.
|
|
||||||
func stateGroup(state string) string {
|
|
||||||
switch state {
|
|
||||||
case "review", "deferred":
|
|
||||||
return "review"
|
|
||||||
case "downloading", "completed", "recognizing", "linking":
|
|
||||||
return "active"
|
|
||||||
case "done":
|
|
||||||
return "done"
|
|
||||||
case "stuck", "failed", "target_missing", "orphaned":
|
|
||||||
return "problem"
|
|
||||||
default:
|
|
||||||
return "other"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// render отрисовывает именованный шаблон в буфер и только затем пишет ответ —
|
// render отрисовывает именованный шаблон в буфер и только затем пишет ответ —
|
||||||
// при ошибке шаблона клиент не получит «полустраницу».
|
// при ошибке шаблона клиент не получит «полустраницу».
|
||||||
func (s *server) render(w http.ResponseWriter, name string, data any) {
|
func (s *server) render(w http.ResponseWriter, name string, data any) {
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ type stubReader struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil }
|
func (s stubReader) ListDownloads(context.Context) ([]store.Download, error) { return s.list, nil }
|
||||||
|
func (s stubReader) ListDownloadsPage(context.Context, store.ListFilter) ([]store.Download, int, error) {
|
||||||
|
return s.list, len(s.list), nil
|
||||||
|
}
|
||||||
func (s stubReader) GetDownload(context.Context, int64) (*store.Download, error) {
|
func (s stubReader) GetDownload(context.Context, int64) (*store.Download, error) {
|
||||||
if s.one == nil {
|
if s.one == nil {
|
||||||
return nil, store.ErrNotFound
|
return nil, store.ErrNotFound
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ type reviewView struct {
|
|||||||
Year int
|
Year int
|
||||||
Provider string
|
Provider string
|
||||||
ProviderID string
|
ProviderID string
|
||||||
|
MatchURL string // ссылка на подтверждённую запись метабазы (пусто — текстом)
|
||||||
Confidence string
|
Confidence string
|
||||||
Reasons []string
|
Reasons []string
|
||||||
Hints []string
|
Hints []string
|
||||||
@@ -102,6 +103,7 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
|||||||
default:
|
default:
|
||||||
view.Provider = rd.Provider
|
view.Provider = rd.Provider
|
||||||
view.ProviderID = rd.ProviderID
|
view.ProviderID = rd.ProviderID
|
||||||
|
view.MatchURL = matchURL(rd, string(rd.Plan.Type))
|
||||||
}
|
}
|
||||||
if rec.Confidence.Valid {
|
if rec.Confidence.Valid {
|
||||||
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
||||||
@@ -253,6 +255,47 @@ func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(co
|
|||||||
redirectReview(w, r, id, "")
|
redirectReview(w, r, id, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// matchURL выбирает ссылку на подтверждённую запись метабазы. Приоритет — URL
|
||||||
|
// выбранного кандидата, но только если его provider+id совпадают с эффективными
|
||||||
|
// (человек мог выбрать кандидата, затем вручную переопределить id — тогда
|
||||||
|
// кандидат указывает на другую запись). Иначе строим канонический URL; если не
|
||||||
|
// удаётся — возвращаем пусто (транспорт покажет матч текстом).
|
||||||
|
func matchURL(rd *worker.ReviewData, mediaType string) string {
|
||||||
|
for _, c := range rd.Candidates {
|
||||||
|
if c.Chosen && c.Provider == rd.Provider && c.ProviderID == rd.ProviderID &&
|
||||||
|
c.URL.Valid && c.URL.String != "" {
|
||||||
|
return c.URL.String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return providerURL(rd.Provider, rd.ProviderID, mediaType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// providerURL строит канонический URL записи метабазы с учётом типа медиа.
|
||||||
|
// Пустой id или неизвестный провайдер → пусто.
|
||||||
|
func providerURL(provider, id, mediaType string) string {
|
||||||
|
if id == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch provider {
|
||||||
|
case "tmdb":
|
||||||
|
kind := "movie"
|
||||||
|
if mediaType == "series" {
|
||||||
|
kind = "tv"
|
||||||
|
}
|
||||||
|
return "https://www.themoviedb.org/" + kind + "/" + id
|
||||||
|
case "tvdb":
|
||||||
|
kind := "series"
|
||||||
|
if mediaType == "movie" {
|
||||||
|
kind = "movie"
|
||||||
|
}
|
||||||
|
return "https://www.thetvdb.com/dereferrer/" + kind + "/" + id
|
||||||
|
case "imdb":
|
||||||
|
return "https://www.imdb.com/title/" + id
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func redirectReview(w http.ResponseWriter, r *http.Request, id int64, msg string) {
|
func redirectReview(w http.ResponseWriter, r *http.Request, id int64, msg string) {
|
||||||
u := "/review/" + strconv.FormatInt(id, 10)
|
u := "/review/" + strconv.FormatInt(id, 10)
|
||||||
if msg != "" {
|
if msg != "" {
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
|||||||
d := &store.Download{
|
d := &store.Download{
|
||||||
SourceType: store.SourceMagnet,
|
SourceType: store.SourceMagnet,
|
||||||
SourceRef: source,
|
SourceRef: source,
|
||||||
|
DisplayName: rename, // то же имя, что уходит в qBittorrent (rename); заголовок в веб-UI
|
||||||
Context: req.Context,
|
Context: req.Context,
|
||||||
Infohash: store.NullString(info.Infohash),
|
Infohash: store.NullString(info.Infohash),
|
||||||
IdempotencyKey: store.NullString(info.Infohash),
|
IdempotencyKey: store.NullString(info.Infohash),
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ func TestIngestSetsDisplayName(t *testing.T) {
|
|||||||
if len(fq.added) != 1 || fq.added[0].Rename != "Дюна: Часть вторая (2024)" {
|
if len(fq.added) != 1 || fq.added[0].Rename != "Дюна: Часть вторая (2024)" {
|
||||||
t.Errorf("rename = %q, want %q", fq.added[0].Rename, "Дюна: Часть вторая (2024)")
|
t.Errorf("rename = %q, want %q", fq.added[0].Rename, "Дюна: Часть вторая (2024)")
|
||||||
}
|
}
|
||||||
|
// То же имя сохраняется у загрузки — заголовок в веб-UI.
|
||||||
|
if len(fs.created) != 1 || fs.created[0].DisplayName != "Дюна: Часть вторая (2024)" {
|
||||||
|
t.Errorf("display_name = %q, want %q", fs.created[0].DisplayName, "Дюна: Часть вторая (2024)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIngestEmptyNameOmitsRename(t *testing.T) {
|
func TestIngestEmptyNameOmitsRename(t *testing.T) {
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ type Download struct {
|
|||||||
ID int64 `db:"id"`
|
ID int64 `db:"id"`
|
||||||
SourceType SourceType `db:"source_type"`
|
SourceType SourceType `db:"source_type"`
|
||||||
SourceRef string `db:"source_ref"`
|
SourceRef string `db:"source_ref"`
|
||||||
|
DisplayName string `db:"display_name"` // имя раздачи (rename в qBittorrent), заголовок в веб-UI
|
||||||
Context string `db:"context"`
|
Context string `db:"context"`
|
||||||
Infohash sql.NullString `db:"infohash"`
|
Infohash sql.NullString `db:"infohash"`
|
||||||
IdempotencyKey sql.NullString `db:"idempotency_key"`
|
IdempotencyKey sql.NullString `db:"idempotency_key"`
|
||||||
@@ -82,9 +83,18 @@ type Download struct {
|
|||||||
ErrorMsg sql.NullString `db:"error_msg"`
|
ErrorMsg sql.NullString `db:"error_msg"`
|
||||||
// SourceMissCount — счётчик подряд идущих тиков сверки без раздачи в
|
// SourceMissCount — счётчик подряд идущих тиков сверки без раздачи в
|
||||||
// qBittorrent (дебаунс пропажи источника, см. state-reconciliation).
|
// qBittorrent (дебаунс пропажи источника, см. state-reconciliation).
|
||||||
SourceMissCount int `db:"source_miss_count"`
|
SourceMissCount int `db:"source_miss_count"`
|
||||||
CreatedAt string `db:"created_at"`
|
// SourceAddedAt — время добавления торрента в qBittorrent (added_on), базис
|
||||||
UpdatedAt string `db:"updated_at"`
|
// сортировки списка. NULL, пока воркер не наблюдал раздачу. Хранится в
|
||||||
|
// формате sqliteTimeLayout (UTC), как created_at.
|
||||||
|
SourceAddedAt sql.NullString `db:"source_added_at"`
|
||||||
|
CreatedAt string `db:"created_at"`
|
||||||
|
UpdatedAt string `db:"updated_at"`
|
||||||
|
|
||||||
|
// RecTitle — распознанное название текущей попытки (LEFT JOIN recognition).
|
||||||
|
// Заполняется только листингом ListDownloadsPage для фолбека заголовка; в
|
||||||
|
// прочих выборках остаётся пустым.
|
||||||
|
RecTitle sql.NullString `db:"rec_title"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// sqliteTimeLayout — формат меток datetime('now') в SQLite (UTC).
|
// sqliteTimeLayout — формат меток datetime('now') в SQLite (UTC).
|
||||||
@@ -95,6 +105,14 @@ func ParseTime(s string) (time.Time, error) {
|
|||||||
return time.ParseInLocation(sqliteTimeLayout, s, time.UTC)
|
return time.ParseInLocation(sqliteTimeLayout, s, time.UTC)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FormatTime форматирует время в формат меток SQLite (datetime('now'), UTC) —
|
||||||
|
// байт-в-байт тот же, что у created_at/updated_at. Нужен, чтобы лексикографи-
|
||||||
|
// ческое сравнение строк времени (COALESCE(source_added_at, created_at) в
|
||||||
|
// сортировке списка) давало верный хронологический порядок.
|
||||||
|
func FormatTime(t time.Time) string {
|
||||||
|
return t.UTC().Format(sqliteTimeLayout)
|
||||||
|
}
|
||||||
|
|
||||||
// CreatedTime возвращает время создания загрузки как time.Time (UTC).
|
// CreatedTime возвращает время создания загрузки как time.Time (UTC).
|
||||||
func (d Download) CreatedTime() (time.Time, error) { return ParseTime(d.CreatedAt) }
|
func (d Download) CreatedTime() (time.Time, error) { return ParseTime(d.CreatedAt) }
|
||||||
|
|
||||||
@@ -106,10 +124,10 @@ func NullString(s string) sql.NullString {
|
|||||||
// CreateDownload вставляет загрузку и возвращает её id.
|
// CreateDownload вставляет загрузку и возвращает её id.
|
||||||
func (s *Store) CreateDownload(ctx context.Context, d *Download) (int64, error) {
|
func (s *Store) CreateDownload(ctx context.Context, d *Download) (int64, error) {
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO download (source_type, source_ref, context, infohash, idempotency_key, state)
|
INSERT INTO download (source_type, source_ref, display_name, context, infohash, idempotency_key, state)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
res, err := s.DB.ExecContext(ctx, q,
|
res, err := s.DB.ExecContext(ctx, q,
|
||||||
d.SourceType, d.SourceRef, d.Context, d.Infohash, d.IdempotencyKey, d.State)
|
d.SourceType, d.SourceRef, d.DisplayName, d.Context, d.Infohash, d.IdempotencyKey, d.State)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("insert download: %w", err)
|
return 0, fmt.Errorf("insert download: %w", err)
|
||||||
}
|
}
|
||||||
@@ -132,6 +150,17 @@ func (s *Store) GetDownload(ctx context.Context, id int64) (*Download, error) {
|
|||||||
return &d, nil
|
return &d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSourceAddedAt однократно фиксирует время добавления торрента в источник
|
||||||
|
// (qBittorrent added_on). SQL-гард `source_added_at IS NULL` не даёт перезапи-
|
||||||
|
// сать значение на повторных наблюдениях: время добавления неизменно.
|
||||||
|
func (s *Store) SetSourceAddedAt(ctx context.Context, id int64, t time.Time) error {
|
||||||
|
const q = `UPDATE download SET source_added_at = ? WHERE id = ? AND source_added_at IS NULL`
|
||||||
|
if _, err := s.DB.ExecContext(ctx, q, FormatTime(t), id); err != nil {
|
||||||
|
return fmt.Errorf("set source added at %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListDownloads возвращает все загрузки, новые сверху.
|
// ListDownloads возвращает все загрузки, новые сверху.
|
||||||
func (s *Store) ListDownloads(ctx context.Context) ([]Download, error) {
|
func (s *Store) ListDownloads(ctx context.Context) ([]Download, error) {
|
||||||
var out []Download
|
var out []Download
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StateGroup — группа состояний для фильтра списка загрузок. Единый источник
|
||||||
|
// истины «группа → состояния» для транспорта (чипы) и запроса (WHERE).
|
||||||
|
type StateGroup string
|
||||||
|
|
||||||
|
const (
|
||||||
|
GroupAll StateGroup = "all" // все, кроме deleted (если не IncludeDeleted)
|
||||||
|
GroupReview StateGroup = "review" // ждут человека
|
||||||
|
GroupActive StateGroup = "active" // в работе
|
||||||
|
GroupDone StateGroup = "done" // готово
|
||||||
|
GroupProblem StateGroup = "problem" // проблемы
|
||||||
|
)
|
||||||
|
|
||||||
|
// statesInGroup возвращает состояния группы фильтра. nil — группа не сужает
|
||||||
|
// выборку по состоянию (all/неизвестная): состояния перечисляются здесь и
|
||||||
|
// нигде больше, чтобы UI и запрос не разъезжались. Терминальные cancelled/
|
||||||
|
// reverted/deleted своей группы (чипа) не имеют — под all они видны (deleted —
|
||||||
|
// только при IncludeDeleted), точечно к ним не фильтруем (см. tasks 2.1).
|
||||||
|
func statesInGroup(g StateGroup) []State {
|
||||||
|
switch g {
|
||||||
|
case GroupReview:
|
||||||
|
return []State{StateReview, StateDeferred}
|
||||||
|
case GroupActive:
|
||||||
|
return []State{StateDownloading, StateCompleted, StateRecognizing, StateLinking}
|
||||||
|
case GroupDone:
|
||||||
|
return []State{StateDone}
|
||||||
|
case GroupProblem:
|
||||||
|
return []State{StateStuck, StateFailed, StateTargetMissing, StateOrphaned}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListFilter — параметры страницы списка загрузок.
|
||||||
|
type ListFilter struct {
|
||||||
|
Group StateGroup // группа состояний ("" / all — без сужения)
|
||||||
|
Query string // поиск по названию/infohash/контексту (подстрока)
|
||||||
|
IncludeDeleted bool // показывать состояние deleted (только под all)
|
||||||
|
Limit int // размер страницы
|
||||||
|
Offset int // смещение страницы
|
||||||
|
}
|
||||||
|
|
||||||
|
// listWhere строит условие WHERE и его аргументы по фильтру.
|
||||||
|
func listWhere(f ListFilter) (string, []any) {
|
||||||
|
var conds []string
|
||||||
|
var args []any
|
||||||
|
|
||||||
|
if states := statesInGroup(f.Group); states != nil {
|
||||||
|
ph := make([]string, len(states))
|
||||||
|
for i, st := range states {
|
||||||
|
ph[i] = "?"
|
||||||
|
args = append(args, string(st))
|
||||||
|
}
|
||||||
|
conds = append(conds, "state IN ("+strings.Join(ph, ",")+")")
|
||||||
|
} else if !f.IncludeDeleted {
|
||||||
|
// Группа all: скрываем удалённые по умолчанию (шум в ленте).
|
||||||
|
conds = append(conds, "state <> ?")
|
||||||
|
args = append(args, string(StateDeleted))
|
||||||
|
}
|
||||||
|
|
||||||
|
if q := strings.TrimSpace(f.Query); q != "" {
|
||||||
|
// LIKE даёт регистронезависимость только для ASCII (встроенное
|
||||||
|
// case-folding SQLite), для кириллицы поиск чувствителен к регистру —
|
||||||
|
// ICU в чистом modernc-драйвере нет. Достаточно для инфохэша/латиницы;
|
||||||
|
// кириллические названия ищем как введены.
|
||||||
|
like := "%" + q + "%"
|
||||||
|
conds = append(conds, "(source_ref LIKE ? COLLATE NOCASE "+
|
||||||
|
"OR display_name LIKE ? COLLATE NOCASE "+
|
||||||
|
"OR context LIKE ? COLLATE NOCASE "+
|
||||||
|
"OR IFNULL(infohash,'') LIKE ? COLLATE NOCASE)")
|
||||||
|
args = append(args, like, like, like, like)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(conds) == 0 {
|
||||||
|
return "", args
|
||||||
|
}
|
||||||
|
return " WHERE " + strings.Join(conds, " AND "), args
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDownloadsPage возвращает страницу загрузок под фильтром и общее число
|
||||||
|
// строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени
|
||||||
|
// добавления в источник (source_added_at) с фолбеком на created_at и tie-break
|
||||||
|
// по id: без вторичного ключа при равных метках порядок неустойчив, и строки
|
||||||
|
// задваивались бы/пропадали между страницами. RecTitle подтягивается LEFT JOIN
|
||||||
|
// текущей попытки распознавания — для фолбека заголовка карточки.
|
||||||
|
func (s *Store) ListDownloadsPage(ctx context.Context, f ListFilter) ([]Download, int, error) {
|
||||||
|
where, args := listWhere(f)
|
||||||
|
|
||||||
|
var total int
|
||||||
|
if err := s.DB.GetContext(ctx, &total, `SELECT COUNT(*) FROM download`+where, args...); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("list downloads page count: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
q := `SELECT download.*, r.title AS rec_title
|
||||||
|
FROM download
|
||||||
|
LEFT JOIN recognition r ON r.download_id = download.id AND r.is_current = 1` + where +
|
||||||
|
` ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC
|
||||||
|
LIMIT ? OFFSET ?`
|
||||||
|
pageArgs := append(append([]any{}, args...), f.Limit, f.Offset)
|
||||||
|
|
||||||
|
var out []Download
|
||||||
|
if err := s.DB.SelectContext(ctx, &out, q, pageArgs...); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("list downloads page: %w", err)
|
||||||
|
}
|
||||||
|
return out, total, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hashN — детерминированный валидный 40-hex infohash по номеру.
|
||||||
|
func hashN(n int) string { return fmt.Sprintf("%040x", n) }
|
||||||
|
|
||||||
|
// mkDownload заводит загрузку в заданном состоянии с display_name.
|
||||||
|
func mkDownload(t *testing.T, st *Store, n int, state State, display string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
d := newDownloading(hashN(n))
|
||||||
|
d.DisplayName = display
|
||||||
|
id, err := st.CreateDownload(ctx, d)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create #%d: %v", n, err)
|
||||||
|
}
|
||||||
|
if state != StateDownloading {
|
||||||
|
if err := st.SetDownloadState(ctx, id, state, "", ""); err != nil {
|
||||||
|
t.Fatalf("set state #%d: %v", n, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func ids(ds []Download) []int64 {
|
||||||
|
out := make([]int64, len(ds))
|
||||||
|
for i, d := range ds {
|
||||||
|
out[i] = d.ID
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDownloadsPageFilterAndDeleted(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
review := mkDownload(t, st, 1, StateReview, "Дюна")
|
||||||
|
active := mkDownload(t, st, 2, StateDownloading, "Fargo")
|
||||||
|
done := mkDownload(t, st, 3, StateDone, "Оно")
|
||||||
|
deleted := mkDownload(t, st, 4, StateDeleted, "Старьё")
|
||||||
|
|
||||||
|
// Группа review — только review/deferred.
|
||||||
|
page, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupReview, Limit: 50})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if total != 1 || len(page) != 1 || page[0].ID != review {
|
||||||
|
t.Fatalf("review group = %v (total %d), want [%d]", ids(page), total, review)
|
||||||
|
}
|
||||||
|
|
||||||
|
// all: deleted скрыт по умолчанию.
|
||||||
|
page, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if total != 3 {
|
||||||
|
t.Fatalf("all (без deleted) total = %d, want 3; ids=%v", total, ids(page))
|
||||||
|
}
|
||||||
|
for _, d := range page {
|
||||||
|
if d.ID == deleted {
|
||||||
|
t.Fatalf("deleted не должен быть виден по умолчанию")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = active
|
||||||
|
_ = done
|
||||||
|
|
||||||
|
// all + IncludeDeleted: показываем всё.
|
||||||
|
_, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, IncludeDeleted: true, Limit: 50})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if total != 4 {
|
||||||
|
t.Fatalf("all+deleted total = %d, want 4", total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDownloadsPageSearch(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mkDownload(t, st, 1, StateReview, "Дюна: Часть вторая")
|
||||||
|
mkDownload(t, st, 2, StateReview, "Fargo")
|
||||||
|
|
||||||
|
// Поиск по display_name (кириллица — как введено, с учётом регистра).
|
||||||
|
page, total, err := st.ListDownloadsPage(ctx, ListFilter{Query: "Дюна", Limit: 50})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if total != 1 || len(page) != 1 || page[0].DisplayName != "Дюна: Часть вторая" {
|
||||||
|
t.Fatalf("search 'Дюна' = %v (total %d)", ids(page), total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Латиница ищется регистронезависимо (ASCII case-folding LIKE).
|
||||||
|
page, _, err = st.ListDownloadsPage(ctx, ListFilter{Query: "fargo", Limit: 50})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(page) != 1 || page[0].DisplayName != "Fargo" {
|
||||||
|
t.Fatalf("search 'fargo' = %v", ids(page))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск по infohash.
|
||||||
|
page, _, err = st.ListDownloadsPage(ctx, ListFilter{Query: hashN(2), Limit: 50})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(page) != 1 || page[0].Infohash.String != hashN(2) {
|
||||||
|
t.Fatalf("search by infohash = %v", ids(page))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDownloadsPageOrderAndPagination(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Три задачи; у #1 и #2 задано source_added_at (разное), у #3 — нет (фолбек
|
||||||
|
// на created_at). Проверяем порядок DESC по времени добавления и tie-break.
|
||||||
|
id1 := mkDownload(t, st, 1, StateDownloading, "one")
|
||||||
|
id2 := mkDownload(t, st, 2, StateDownloading, "two")
|
||||||
|
id3 := mkDownload(t, st, 3, StateDownloading, "three")
|
||||||
|
|
||||||
|
base := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
// id1 добавлен раньше, id2 позже → id2 должен идти выше id1.
|
||||||
|
if err := st.SetSourceAddedAt(ctx, id1, base); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.SetSourceAddedAt(ctx, id2, base.Add(time.Hour)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
page, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if total != 3 {
|
||||||
|
t.Fatalf("total = %d, want 3", total)
|
||||||
|
}
|
||||||
|
// id3 фолбечит на created_at (~сейчас, 2026-07-01) — свежее, чем добавления
|
||||||
|
// id1/id2 в июне → id3 первым; затем id2 (позже добавлен), затем id1.
|
||||||
|
got := ids(page)
|
||||||
|
want := []int64{id3, id2, id1}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("порядок = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пагинация: по 2 на страницу.
|
||||||
|
p1, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 2, Offset: 0})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if total != 3 || len(p1) != 2 {
|
||||||
|
t.Fatalf("page1 len=%d total=%d, want 2/3", len(p1), total)
|
||||||
|
}
|
||||||
|
p2, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 2, Offset: 2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(p2) != 1 {
|
||||||
|
t.Fatalf("page2 len=%d, want 1", len(p2))
|
||||||
|
}
|
||||||
|
// Страницы не пересекаются и покрывают весь список.
|
||||||
|
if p1[0].ID != id3 || p1[1].ID != id2 || p2[0].ID != id1 {
|
||||||
|
t.Fatalf("страницы разъехались: p1=%v p2=%v", ids(p1), ids(p2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDownloadsPageTieBreakByID(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Одинаковое время добавления у всех → устойчивый порядок по id DESC.
|
||||||
|
same := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC)
|
||||||
|
var idList []int64
|
||||||
|
for i := 1; i <= 3; i++ {
|
||||||
|
id := mkDownload(t, st, i, StateDownloading, fmt.Sprintf("d%d", i))
|
||||||
|
if err := st.SetSourceAddedAt(ctx, id, same); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
idList = append(idList, id)
|
||||||
|
}
|
||||||
|
page, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := ids(page)
|
||||||
|
want := []int64{idList[2], idList[1], idList[0]} // id DESC
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("tie-break порядок = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetSourceAddedAtOnce(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id := mkDownload(t, st, 1, StateDownloading, "x")
|
||||||
|
first := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
second := time.Date(2026, 6, 2, 10, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
if err := st.SetSourceAddedAt(ctx, id, first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Повторная запись не должна перезаписать (гард IS NULL).
|
||||||
|
if err := st.SetSourceAddedAt(ctx, id, second); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
d, err := st.GetDownload(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !d.SourceAddedAt.Valid || d.SourceAddedAt.String != FormatTime(first) {
|
||||||
|
t.Fatalf("source_added_at = %q, want %q (первое значение неизменно)",
|
||||||
|
d.SourceAddedAt.String, FormatTime(first))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDownloadsPageRecTitleFallback(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Загрузка без display_name, но с распознанным названием — RecTitle из join.
|
||||||
|
id := mkDownload(t, st, 1, StateReview, "")
|
||||||
|
if _, err := st.CreateRecognition(ctx, &Recognition{
|
||||||
|
DownloadID: id,
|
||||||
|
Title: NullString("Настоящее название"),
|
||||||
|
}, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
page, _, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(page) != 1 {
|
||||||
|
t.Fatalf("len = %d", len(page))
|
||||||
|
}
|
||||||
|
if !page[0].RecTitle.Valid || page[0].RecTitle.String != "Настоящее название" {
|
||||||
|
t.Fatalf("RecTitle = %q, want распознанное название", page[0].RecTitle.String)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateDownloadPersistsDisplayName(t *testing.T) {
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id := mkDownload(t, st, 1, StateDownloading, "Дюна: Часть вторая (2024)")
|
||||||
|
d, err := st.GetDownload(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if d.DisplayName != "Дюна: Часть вторая (2024)" {
|
||||||
|
t.Fatalf("display_name = %q", d.DisplayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Отображаемое имя раздачи (то, что уходит в qBittorrent как rename) — для
|
||||||
|
-- заголовка карточки/страницы в веб-UI. source_added_at — время добавления
|
||||||
|
-- торрента в qBittorrent (added_on), базис сортировки списка; nullable, пока
|
||||||
|
-- воркер не наблюдал раздачу.
|
||||||
|
ALTER TABLE download ADD COLUMN display_name TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE download ADD COLUMN source_added_at TEXT;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE download DROP COLUMN source_added_at;
|
||||||
|
ALTER TABLE download DROP COLUMN display_name;
|
||||||
@@ -3,6 +3,7 @@ package worker
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||||
@@ -50,6 +51,7 @@ func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
|||||||
d := &store.Download{
|
d := &store.Download{
|
||||||
SourceType: store.SourceMagnet,
|
SourceType: store.SourceMagnet,
|
||||||
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
||||||
|
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
|
||||||
Infohash: store.NullString(infohash),
|
Infohash: store.NullString(infohash),
|
||||||
IdempotencyKey: store.NullString(infohash),
|
IdempotencyKey: store.NullString(infohash),
|
||||||
State: store.StateDownloading,
|
State: store.StateDownloading,
|
||||||
@@ -64,6 +66,14 @@ func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
|||||||
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", infohash, "error", err)
|
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", infohash, "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Базис сортировки — время добавления в источник; у усыновлённого оно уже
|
||||||
|
// известно (created_at задачи было бы моментом усыновления, не добавления).
|
||||||
|
if t.AddedOn > 0 {
|
||||||
|
if err := w.store.SetSourceAddedAt(ctx, id, time.Unix(t.AddedOn, 0)); err != nil {
|
||||||
|
w.log.Warn("adopt set source_added_at failed",
|
||||||
|
"capability", capIngest, "download_id", id, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
w.log.Info("discover adopted torrent",
|
w.log.Info("discover adopted torrent",
|
||||||
"capability", capIngest, "download_id", id, "infohash", infohash, "name", t.Name,
|
"capability", capIngest, "download_id", id, "infohash", infohash, "name", t.Name,
|
||||||
"category", t.Category, "tags", t.Tags)
|
"category", t.Category, "tags", t.Tags)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func TestDiscover_AdoptsByCategory(t *testing.T) {
|
|||||||
st := emptyStore()
|
st := emptyStore()
|
||||||
w := newTestWorker(st, &fakeQbt{})
|
w := newTestWorker(st, &fakeQbt{})
|
||||||
w.discover(context.Background(), []qbt.Torrent{
|
w.discover(context.Background(), []qbt.Torrent{
|
||||||
{Hash: ihDisc, Name: "Avatar", Category: "jellybit", State: "stalledUP"},
|
{Hash: ihDisc, Name: "Avatar", Category: "jellybit", State: "stalledUP", AddedOn: 1_700_000_000},
|
||||||
})
|
})
|
||||||
|
|
||||||
d := findByInfohash(st, ihDisc)
|
d := findByInfohash(st, ihDisc)
|
||||||
@@ -41,6 +41,14 @@ func TestDiscover_AdoptsByCategory(t *testing.T) {
|
|||||||
if d.IdempotencyKey.String != ihDisc {
|
if d.IdempotencyKey.String != ihDisc {
|
||||||
t.Errorf("idempotency_key = %q", d.IdempotencyKey.String)
|
t.Errorf("idempotency_key = %q", d.IdempotencyKey.String)
|
||||||
}
|
}
|
||||||
|
// Усыновление берёт заголовок из имени торрента qBittorrent и фиксирует
|
||||||
|
// время добавления (added_on) как базис сортировки.
|
||||||
|
if d.DisplayName != "Avatar" {
|
||||||
|
t.Errorf("display_name = %q, want Avatar", d.DisplayName)
|
||||||
|
}
|
||||||
|
if !d.SourceAddedAt.Valid {
|
||||||
|
t.Errorf("source_added_at не зафиксирован при усыновлении")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDiscover_AdoptsByTag(t *testing.T) {
|
func TestDiscover_AdoptsByTag(t *testing.T) {
|
||||||
@@ -94,6 +102,25 @@ func TestDiscover_SkipsNoInfohash(t *testing.T) {
|
|||||||
|
|
||||||
// TestPoll_AdoptsAndCompletes — сценарий пользователя целиком: помеченная и
|
// TestPoll_AdoptsAndCompletes — сценарий пользователя целиком: помеченная и
|
||||||
// уже скачанная раздача за один тик усыновляется и доходит до completed.
|
// уже скачанная раздача за один тик усыновляется и доходит до completed.
|
||||||
|
func TestPoll_CapturesSourceAddedAt(t *testing.T) {
|
||||||
|
ih := "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
|
||||||
|
st := &fakeStore{downloads: map[int64]*store.Download{
|
||||||
|
1: {ID: 1, State: store.StateDownloading, SourceType: store.SourceMagnet,
|
||||||
|
Infohash: store.NullString(ih), IdempotencyKey: store.NullString(ih)},
|
||||||
|
}}
|
||||||
|
qb := &fakeQbt{torrents: []qbt.Torrent{
|
||||||
|
{Hash: ih, Name: "X", Category: "jellybit", State: "downloading", AddedOn: 1_700_000_000},
|
||||||
|
}}
|
||||||
|
w := newTestWorker(st, qb)
|
||||||
|
|
||||||
|
if err := w.Poll(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Poll: %v", err)
|
||||||
|
}
|
||||||
|
if d := st.downloads[1]; !d.SourceAddedAt.Valid {
|
||||||
|
t.Fatalf("source_added_at не захвачен при поллинге активной задачи")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPoll_AdoptsAndCompletes(t *testing.T) {
|
func TestPoll_AdoptsAndCompletes(t *testing.T) {
|
||||||
st := emptyStore()
|
st := emptyStore()
|
||||||
qb := &fakeQbt{torrents: []qbt.Torrent{
|
qb := &fakeQbt{torrents: []qbt.Torrent{
|
||||||
|
|||||||
@@ -317,6 +317,13 @@ func (m *memStore) SetSourceMissCount(_ context.Context, id int64, n int) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *memStore) SetSourceAddedAt(_ context.Context, id int64, t time.Time) error {
|
||||||
|
if d, ok := m.downloads[id]; ok && !d.SourceAddedAt.Valid {
|
||||||
|
d.SourceAddedAt = store.NullString(store.FormatTime(t))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, reasons []string) (int64, error) {
|
func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, reasons []string) (int64, error) {
|
||||||
for _, e := range m.recs {
|
for _, e := range m.recs {
|
||||||
if e.DownloadID == r.DownloadID {
|
if e.DownloadID == r.DownloadID {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type Store interface {
|
|||||||
GetDownload(ctx context.Context, id int64) (*store.Download, error)
|
GetDownload(ctx context.Context, id int64) (*store.Download, error)
|
||||||
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
|
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
|
||||||
SetSourceMissCount(ctx context.Context, id int64, n int) error
|
SetSourceMissCount(ctx context.Context, id int64, n int) error
|
||||||
|
SetSourceAddedAt(ctx context.Context, id int64, t time.Time) error
|
||||||
|
|
||||||
// Discovery (усыновление раздач по категории/тегу).
|
// Discovery (усыновление раздач по категории/тегу).
|
||||||
ExistsByInfohash(ctx context.Context, infohash string) (bool, error)
|
ExistsByInfohash(ctx context.Context, infohash string) (bool, error)
|
||||||
@@ -333,6 +334,7 @@ func (w *Worker) Poll(ctx context.Context) error {
|
|||||||
"capability", capIngest, "download_id", d.ID, "infohash", d.Infohash.String)
|
"capability", capIngest, "download_id", d.ID, "infohash", d.Infohash.String)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
w.captureSourceAddedAt(ctx, d, t)
|
||||||
w.reconcile(ctx, d, t)
|
w.reconcile(ctx, d, t)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,6 +382,21 @@ func (w *Worker) checkTimeouts(ctx context.Context, d store.Download, t qbt.Torr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// captureSourceAddedAt однократно сохраняет время добавления торрента в
|
||||||
|
// qBittorrent (added_on) у задачи — базис сортировки списка. Пишем только при
|
||||||
|
// первом наблюдении (в БД source_added_at ещё пуст, SQL-гард в store); значение
|
||||||
|
// неизменно, поэтому повторные тики его не трогают. Учётная операция: её сбой не
|
||||||
|
// двигает задачу, лишь логируем WARN. Вызывается под w.mu.
|
||||||
|
func (w *Worker) captureSourceAddedAt(ctx context.Context, d store.Download, t qbt.Torrent) {
|
||||||
|
if d.SourceAddedAt.Valid || t.AddedOn <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := w.store.SetSourceAddedAt(ctx, d.ID, time.Unix(t.AddedOn, 0)); err != nil {
|
||||||
|
w.log.Warn("capture source_added_at failed",
|
||||||
|
"capability", capIngest, "download_id", d.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// torrentAge — возраст торрента: от added_on в qBittorrent (надёжный базис,
|
// torrentAge — возраст торрента: от added_on в qBittorrent (надёжный базис,
|
||||||
// переживает retry/усыновление), с фолбэком на created_at задачи, если qBit не
|
// переживает retry/усыновление), с фолбэком на created_at задачи, если qBit не
|
||||||
// отдал added_on.
|
// отдал added_on.
|
||||||
|
|||||||
@@ -115,6 +115,17 @@ func (f *fakeStore) SetSourceMissCount(_ context.Context, id int64, n int) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeStore) SetSourceAddedAt(_ context.Context, id int64, t time.Time) error {
|
||||||
|
d, ok := f.downloads[id]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("download %d not found", id)
|
||||||
|
}
|
||||||
|
if !d.SourceAddedAt.Valid { // гард как в store: пишем однократно
|
||||||
|
d.SourceAddedAt = store.NullString(store.FormatTime(t))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) ---
|
// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) ---
|
||||||
|
|
||||||
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (int64, error) {
|
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (int64, error) {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-01
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
Современный server-rendered веб-UI уже даёт список-карточки, страницу просмотра
|
||||||
|
`/download/{id}`, ревью и живой прогресс (htmx-поллинг снимка воркера). Осталось
|
||||||
|
закрыть хвосты беклога по UI/UX. Текущее состояние по пунктам:
|
||||||
|
|
||||||
|
- **Список** (`handleIndex` + `index.html`): грузит `ListDownloads` целиком,
|
||||||
|
фильтр-чипы и поиск — клиентский JS по `data-group`/`data-text` уже
|
||||||
|
отрендеренных карточек, `deleted` прячется чекбоксом `showAll`. Пагинации нет.
|
||||||
|
- **Заголовок** карточки и шапки страницы — `d.SourceRef` (для magnet это сырая
|
||||||
|
ссылка). Красивое имя есть только в блоке «Распознано как» и только после
|
||||||
|
распознавания (`rd.Plan.Title`). Имя `rename`, уходящее в qBittorrent при
|
||||||
|
приёме (`internal/ingest`, `Namer.DeriveName`), нигде не сохраняется.
|
||||||
|
- **Матч метабазы**: на ревью кандидаты уже показываются со ссылкой
|
||||||
|
(`candidate_url`, миграция 0004); но подтверждённый выбор («Выбрано: provider
|
||||||
|
id») и блок «Распознано как» на `/download/{id}` ссылки не имеют.
|
||||||
|
- **Полировка**: в «Распознано как» список полей ограничен `max-width:360px`; в
|
||||||
|
layout-виджете стрелка не приклеена к строке файла-источника.
|
||||||
|
|
||||||
|
Ограничения: один статический бинарь, без сборки фронта и реактивных фреймворков;
|
||||||
|
чтение — через `store`, тонкий транспорт `httpapi`; SQLite (одиночное соединение,
|
||||||
|
сериализация записи). Целевой масштаб — сотни–тысячи строк `download`.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Список масштабируется на рост БД: серверные фильтр по состоянию, поиск и
|
||||||
|
пагинация через GET-параметры, работающие без JS и шарящиеся ссылкой.
|
||||||
|
- Заголовок загрузки — человекочитаемое имя раздачи qBittorrent, доступное с
|
||||||
|
первого кадра (до распознавания), с внятным фолбеком.
|
||||||
|
- Подтверждённый матч метабазы виден ссылкой на запись во всех веб-местах
|
||||||
|
(карточка просмотра, «Источник совпадения» ревью).
|
||||||
|
- Сырой источник/magnet не мозолит глаза в заголовке — вынесен в отдельный блок.
|
||||||
|
- Мелкие дефекты вёрстки устранены.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- Полный лог переходов состояний (нужна таблица истории — отдельная задача).
|
||||||
|
- Собственный идентификатор загрузки, слияние по infohash.
|
||||||
|
- Полноценный «выбор источника матча с предпросмотром полей» (отдельная задача
|
||||||
|
беклога); здесь — только ссылка на уже подтверждённый матч.
|
||||||
|
- Показ матча в Telegram, перевод живых обновлений на SSE.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Список: серверные фильтр/поиск/пагинация через GET-параметры
|
||||||
|
|
||||||
|
Параметры: `f` (группа состояний: `all|review|active|done|problem`, по умолчанию
|
||||||
|
`all` без `deleted`), `q` (строка поиска), `page` (1-based), плюс `all=1`
|
||||||
|
(показать в т.ч. `deleted`). Размер страницы — константа в `httpapi` (**25**).
|
||||||
|
|
||||||
|
Новый метод `store` возвращает страницу + общее число строк под фильтром для
|
||||||
|
рендера пагинации: например `ListDownloadsPage(ctx, ListFilter) ([]Download,
|
||||||
|
int, error)`, где `ListFilter{Group, Query, Deleted, Limit, Offset}`. В SQL:
|
||||||
|
`WHERE` по множеству состояний группы (маппинг из существующего `stateGroup`,
|
||||||
|
вынести в общий источник истины состояние↔группа), `deleted` включается только
|
||||||
|
при `all`; поиск — `LIKE '%'||?||'%'` по `source_ref`, `display_name`, `context`,
|
||||||
|
`infohash` (регистронезависимо, `COLLATE NOCASE`); сортировка
|
||||||
|
`ORDER BY COALESCE(source_added_at, created_at) DESC, id DESC` (см. решение 5);
|
||||||
|
`LIMIT/OFFSET`. Итог count — отдельный `COUNT(*)` с тем же `WHERE`.
|
||||||
|
|
||||||
|
`page` клэмпится: `< 1`/нечисловой → `1`; за последней страницей → пустая
|
||||||
|
страница (не ошибка). Метасимволы `%`/`_` в `q` не экранируем (параметризованный
|
||||||
|
`LIKE`, не security-issue; при необходимости добавим `ESCAPE` позже).
|
||||||
|
Пустое состояние (`len(page)==0`) рендерит сервер: «ничего не найдено», если
|
||||||
|
активны фильтр/поиск, иначе «пока пусто» — клиентского JS для этого больше нет.
|
||||||
|
|
||||||
|
**Почему GET, а не htmx/JSON:** закладки/шаринг, работа без JS, минимум кода;
|
||||||
|
согласуется с «клиентская логика без сборки». Пагинация и чипы — обычные ссылки,
|
||||||
|
сохраняющие текущие `f`/`q`. Клиентская JS-фильтрация удаляется (заменена
|
||||||
|
серверной), копирование infohash и `<details>` остаются.
|
||||||
|
|
||||||
|
**Живой прогресс:** карточки текущей страницы по-прежнему сами поллят свой
|
||||||
|
фрагмент прогресса — механика `live-status` не затрагивается. Форма поиска —
|
||||||
|
обычный GET-submit; фильтр-чипы — ссылки.
|
||||||
|
|
||||||
|
Альтернатива (клиентская пагинация или «Только пагинация» с клиентским
|
||||||
|
фильтром) отвергнута: фильтрует лишь текущую страницу и вводит в заблуждение.
|
||||||
|
|
||||||
|
### 2. Персистентность отображаемого имени (`display_name`)
|
||||||
|
|
||||||
|
Миграция `0005`: `ALTER TABLE download ADD COLUMN display_name TEXT NOT NULL
|
||||||
|
DEFAULT ''`. При приёме (`internal/ingest`) выведенное имя `rename` пишется и в
|
||||||
|
`download.display_name` (та же строка, что уходит в qBittorrent). Инвариант
|
||||||
|
ingest-спеки сохраняется: имя не влияет на пути/распознавание/раскладку.
|
||||||
|
|
||||||
|
Для **усыновлённых** торрентов (`discover.adopt()`) приёма через `ingest` нет и
|
||||||
|
`rename` не выводится; там `SourceRef` — искусственный `magnet:?xt=urn:btih:…`.
|
||||||
|
Чтобы заголовок таких задач не был голым btih, при усыновлении пишем
|
||||||
|
`display_name = t.Name` (имя торрента из qBittorrent).
|
||||||
|
|
||||||
|
Заголовок в веб-UI (карточка списка и шапка `/download/{id}`) — фолбек-цепочка:
|
||||||
|
|
||||||
|
1. `display_name` (если непустой);
|
||||||
|
2. распознанное `rd.Plan.Title` (если есть план);
|
||||||
|
3. сырой источник (`source_ref`), **усечённый в одну строку** как обычный
|
||||||
|
заголовок.
|
||||||
|
|
||||||
|
Старые строки БД получат `display_name=''` → отработает п.2/п.3, регресса нет.
|
||||||
|
|
||||||
|
**Почему колонка, а не вывод на лету:** `rename` выводится единожды при приёме
|
||||||
|
(в т.ч. через LLM) и в БД сейчас не сохраняется; пересчитывать на каждый рендер
|
||||||
|
дорого и недетерминированно. Хранение — дёшево и даёт стабильный заголовок с
|
||||||
|
первого кадра.
|
||||||
|
|
||||||
|
### 3. Ссылка на запись метабазы
|
||||||
|
|
||||||
|
Приоритет — URL уже сохранённого выбранного кандидата (`candidate_url`,
|
||||||
|
миграция 0004): в `ReviewData` есть `Candidates` с флагом `Chosen` и `URL`
|
||||||
|
(детальная страница их грузит — `handleDownload` → `ReviewData`, проверено).
|
||||||
|
|
||||||
|
**Важно:** URL выбранного кандидата берём только если его `provider`+`id`
|
||||||
|
совпадают с **эффективными** `rd.Provider`/`rd.ProviderID` (пользователь мог
|
||||||
|
выбрать кандидата, а затем вручную переопределить `provider_id` — тогда
|
||||||
|
`Chosen`-кандидат указывает на другую запись). Если не совпадают или URL у
|
||||||
|
кандидата нет — строим канонический URL функцией `providerURL(provider, id,
|
||||||
|
mediaType)`.
|
||||||
|
|
||||||
|
`providerURL` учитывает тип медиа: для `tmdb` — `/movie/{id}` vs `/tv/{id}`
|
||||||
|
(см. `internal/metadata/tmdb.go`), для `tvdb` — медиазависимый путь (см.
|
||||||
|
`internal/metadata/tvdb.go`), для `imdb` — `/title/{id}`. Тип на детальной
|
||||||
|
странице известен (`view.IsSeries`/`rd.Plan.Type`). Если для провайдера/типа
|
||||||
|
надёжный URL не построить — показываем матч текстом (провайдер, id) без ссылки
|
||||||
|
(предусмотрено сценарием «URL записи неизвестен»).
|
||||||
|
|
||||||
|
Ссылка показывается в «Распознано как» на `/download/{id}` и в строке «Выбрано»
|
||||||
|
блока «Источник совпадения» ревью. Ссылки — `target=_blank rel=noopener` (как у
|
||||||
|
кандидатов).
|
||||||
|
|
||||||
|
### 4. Блок «Информация о торренте» на `/download/{id}`
|
||||||
|
|
||||||
|
Отдельная секция: тип источника, полный `source_ref`/magnet и `infohash` с
|
||||||
|
кнопкой копирования. Разгружает заголовок (см. решение 2) и собирает «сырьё» в
|
||||||
|
одном месте.
|
||||||
|
|
||||||
|
### 5. Сортировка списка по времени добавления в источник
|
||||||
|
|
||||||
|
Список сортируется по времени добавления торрента в qBittorrent (`added_on`), с
|
||||||
|
фолбеком на время создания загрузки в jellybit (`created_at`). Причина: jellybit
|
||||||
|
может **захватывать уже добавленные** (усыновлённые) торренты, поэтому
|
||||||
|
`created_at` не отражает реальный порядок появления раздачи; `added_on` — более
|
||||||
|
верный базис (тем же соображением воркер уже считает возраст задачи от
|
||||||
|
`added_on`, см. `worker.torrentAge`).
|
||||||
|
|
||||||
|
`added_on` не хранится в БД — воркер берёт его из живого `qbt.Torrent`. Добавляем
|
||||||
|
колонку `download.source_added_at` (nullable) и **один раз** персистим её из
|
||||||
|
воркера. Значение неизменно (время добавления не меняется), пишем только при
|
||||||
|
первом наблюдении.
|
||||||
|
|
||||||
|
**Формат (критично для сортировки):** `source_added_at` хранится байт-в-байт в
|
||||||
|
том же формате, что `created_at` — `YYYY-MM-DD HH:MM:SS` в UTC
|
||||||
|
(`store.sqliteTimeLayout`). Воркер форматирует
|
||||||
|
`time.Unix(t.AddedOn,0).UTC().Format(...)` через хелпер `store` (не RFC3339 и не
|
||||||
|
число), иначе лексикографическое сравнение TEXT в `COALESCE(...)` даст неверный
|
||||||
|
порядок при смешивании заполненных и фолбек-строк.
|
||||||
|
|
||||||
|
**Точка записи:** в цикле `Poll` сразу после успешного сопоставления активной
|
||||||
|
задачи с торрентом по `byHash` (до `reconcile`), для любого класса состояния
|
||||||
|
торрента — иначе задача, увиденная тиком уже готовой (`classReady` → сразу
|
||||||
|
`transition`), запись пропустит. Для усыновлённых торрентов `added_on` доступен
|
||||||
|
уже в `adopt()` (`discover.go`) — пишем и там. Всё под `w.mu`. Идемпотентность:
|
||||||
|
проверка `d.SourceAddedAt` в Go + SQL-гард `WHERE source_added_at IS NULL` в
|
||||||
|
методе стора (не писать на каждом тике).
|
||||||
|
|
||||||
|
**Сортировка** в запросе списка — `ORDER BY COALESCE(source_added_at,
|
||||||
|
created_at) DESC, id DESC`. Вторичный ключ `id DESC` обязателен: без него при
|
||||||
|
равных метках времени (пакетное добавление в одну секунду) SQLite даёт
|
||||||
|
неустойчивый порядок, и при `LIMIT/OFFSET` строки задваиваются/пропадают между
|
||||||
|
страницами — это нарушило бы требование «порядок согласован между страницами».
|
||||||
|
|
||||||
|
Альтернатива (сортировка по `id DESC` или только `created_at`) отвергнута:
|
||||||
|
порядок разъедется для усыновлённых торрентов.
|
||||||
|
|
||||||
|
### 6. Полировка вёрстки (CSS/шаблоны)
|
||||||
|
|
||||||
|
- «Распознано как»: снять `max-width:360px` у `dl.kv` — блок на всю ширину.
|
||||||
|
- Layout-виджет: приклеить стрелку к строке файла-источника (источник и цель
|
||||||
|
строго друг под другом) — правка `layout_widget.html`/CSS.
|
||||||
|
|
||||||
|
Инвариант дизайн-системы соблюдаем: без инлайн-`<style>` с хардкодом цветов,
|
||||||
|
правки уходят в `jellybit.css`.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- **UX: перезагрузка страницы вместо мгновенной клиентской фильтрации** →
|
||||||
|
GET-параметры быстры на server-rendered UI; форма/ссылки дёшевы; выигрыш —
|
||||||
|
масштаб и шаринг ссылок.
|
||||||
|
- **Производительность `LIKE '%q%'`** (не использует индекс) → на целевом
|
||||||
|
масштабе (сотни–тысячи строк) приемлемо; при нужде позже — FTS/индекс, вне
|
||||||
|
объёма.
|
||||||
|
- **Взаимодействие пагинации с живым прогрессом** → карточки поллят
|
||||||
|
индивидуальные фрагменты по id; пагинация не меняет `live-status`.
|
||||||
|
- **Пустой `display_name` у старых строк** → фолбек-цепочка покрывает; бэкофилл
|
||||||
|
не требуется.
|
||||||
|
- **Ручной `provider_id` без кандидата** → `providerURL` строит ссылку по
|
||||||
|
провайдеру; для неизвестного провайдера ссылку не показываем (только текст).
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Добавить goose-миграцию `0005` — колонки `download.display_name` и
|
||||||
|
`download.source_added_at` (up: `ADD COLUMN`; down: снятие колонок по
|
||||||
|
возможностям SQLite/goose).
|
||||||
|
2. Обновить ER-схему `docs/specs/database.md` (обе новые колонки).
|
||||||
|
3. Писать `display_name` в `ingest` при `CreateDownload`.
|
||||||
|
4. В `worker` персистить `source_added_at` при поллинге (однократно, при первом
|
||||||
|
наблюдении `added_on`).
|
||||||
|
5. Добавить запрос списка с фильтром/поиском/сортировкой/пагинацией и count в `store`.
|
||||||
|
6. Перевести `handleIndex` на параметры + новый запрос, обновить шаблоны и CSS,
|
||||||
|
убрать клиентскую фильтрацию.
|
||||||
|
|
||||||
|
Откат: обратная миграция (down) снимает колонку; UI-код фолбечит на
|
||||||
|
`source_ref`. Изменения аддитивны, REST-контракт `/api/downloads` не меняется.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Размер страницы (25) — уточним по ощущению на реальных данных; вынести в
|
||||||
|
конфиг при необходимости (сейчас константа).
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Пачка UI/UX-задач беклога («Главная: список вместо таблицы», «Расширенная
|
||||||
|
информация о загрузке», «показывать матч с записью метабазы», «список: фильтр,
|
||||||
|
поиск, пагинация», «Полировка веб-UI») частично уже закрыта при внедрении
|
||||||
|
современного server-rendered UI. Осталось довести хвосты: список не масштабируется
|
||||||
|
на рост БД (нет серверной пагинации, фильтр/поиск чисто клиентские по уже
|
||||||
|
загруженным карточкам), подтверждённый матч метабазы показывается без ссылки на
|
||||||
|
запись, заголовок карточки — сырой magnet вместо имени раздачи, и есть мелкие
|
||||||
|
дефекты вёрстки. Собираем это в одно небольшое изменение.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- **Список загрузок — серверные фильтр, поиск и пагинация.** Фильтр по группе
|
||||||
|
состояний, поиск по названию/infohash/контексту и постраничная выдача (по 25)
|
||||||
|
через query-параметры (`?f=`, `?q=`, `?page=`), на стороне `store`/`httpapi`;
|
||||||
|
заменяет текущую клиентскую фильтрацию по отрендеренным карточкам. Сортировка —
|
||||||
|
по времени добавления торрента в источник (qBittorrent `added_on`), фолбек —
|
||||||
|
время создания загрузки в jellybit. Скрытие `deleted` по умолчанию сохраняется.
|
||||||
|
Живой прогресс активных карточек (htmx-поллинг) не ломается.
|
||||||
|
- **Заголовок загрузки = имя раздачи qBittorrent.** При приёме сохраняем
|
||||||
|
выведенное отображаемое имя (`rename`, что уходит в qBittorrent) в новую колонку
|
||||||
|
`display_name`. Заголовок карточки и шапка страницы берут его; если пусто —
|
||||||
|
распознанное название из плана; если и его нет — сырой источник (magnet) **одной
|
||||||
|
строкой как обычный заголовок** (усечённо), а не в три строки.
|
||||||
|
- **Матч с записью метабазы со ссылкой.** На странице просмотра
|
||||||
|
(`/download/{id}`) и в блоке «Источник совпадения» ревью подтверждённый матч
|
||||||
|
(TMDB/TVDB/IMDb) показывается как ссылка на запись (название, год, провайдер-id,
|
||||||
|
URL), а не только `провайдер · id`.
|
||||||
|
- **Блок «Информация о торренте» на странице загрузки.** Полный magnet/сырой
|
||||||
|
источник и infohash выносятся в отдельный блок, чтобы заголовок оставался чистым.
|
||||||
|
- **Полировка вёрстки:** блок «Распознано как» растянуть на всю ширину (снять
|
||||||
|
`max-width` у списка полей); в блоке «Файлы и раскладка» приклеить стрелку к
|
||||||
|
строке файла-источника, чтобы источник и цель шли строго друг под другом.
|
||||||
|
|
||||||
|
Вне объёма (отдельные задачи беклога): полный лог переходов состояний (требует
|
||||||
|
таблицы истории), собственный идентификатор загрузки, выбор источника матча с
|
||||||
|
предпросмотром полей, показ матча в Telegram, SSE.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
Нет.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `web-ui`: список получает серверные фильтр/поиск/пагинацию (было — клиентская
|
||||||
|
фильтрация без пагинации); заголовок загрузки берётся из сохранённого имени
|
||||||
|
раздачи с фолбеком; страница просмотра получает блок «Информация о торренте» и
|
||||||
|
показывает подтверждённый матч метабазы ссылкой на запись.
|
||||||
|
- `ingest`: выведенное отображаемое имя теперь не только уходит в qBittorrent, но
|
||||||
|
и **сохраняется** у загрузки (для показа в веб-UI); прежний инвариант «не влияет
|
||||||
|
на пути/распознавание/раскладку» сохраняется.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **БД (миграция):** новые колонки `download.display_name` и
|
||||||
|
`download.source_added_at` (время добавления в qBittorrent); обновление
|
||||||
|
ER-схемы `docs/specs/database.md`. Новые запросы списка с
|
||||||
|
`WHERE`/`LIKE`/`ORDER BY`/`LIMIT`/`OFFSET` и счётчиком (`store`).
|
||||||
|
- **Код:** `internal/store` (колонки, запрос списка с фильтром/поиском/сортировкой/
|
||||||
|
пагинацией и count), `internal/ingest` (запись `display_name` при создании
|
||||||
|
загрузки), `internal/worker` (захват `added_on` → `source_added_at` при
|
||||||
|
поллинге),
|
||||||
|
`internal/httpapi` (разбор query-параметров списка, view-модели, ссылка на матч,
|
||||||
|
блок торрента, заголовок с фолбеком), шаблоны `web/templates` (index, download,
|
||||||
|
review, layout_widget) и `web/static/css` (полировка), возможно `web/static/js`
|
||||||
|
(упрощение/удаление клиентской фильтрации).
|
||||||
|
- **Обратная совместимость:** REST API (`/api/downloads`) не меняется в контракте;
|
||||||
|
добавление query-параметров списка — аддитивно. Существующие строки БД получат
|
||||||
|
пустой `display_name` (фолбек на распознанное/источник отработает штатно).
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Отображаемое имя торрента из контекста
|
||||||
|
|
||||||
|
При добавлении загрузки в qBittorrent система SHALL выводить из контекста
|
||||||
|
загрузки человекочитаемое отображаемое имя и передавать его в qBittorrent
|
||||||
|
(параметр `rename` API `/torrents/add`), чтобы задача в списке qBit не
|
||||||
|
показывалась безликим `dn` magnet-ссылки. Это же имя система SHALL **сохранять
|
||||||
|
у загрузки** (`download.display_name`) для последующего показа заголовком в
|
||||||
|
веб-UI.
|
||||||
|
|
||||||
|
Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и
|
||||||
|
год; для сериала — номер сезона, если он определён), а не куском сырого
|
||||||
|
контекста. Имя SHALL очищаться от управляющих символов и переводов строк и
|
||||||
|
SHALL обрезаться по ограничению длины.
|
||||||
|
|
||||||
|
Вывод имени SHALL выполняться синхронно перед отдачей источника в
|
||||||
|
qBittorrent (параметр `rename` действует только в момент добавления).
|
||||||
|
|
||||||
|
Отображаемое имя SHALL влиять только на отображение (в qBittorrent и как
|
||||||
|
заголовок в веб-UI) и SHALL NOT влиять на пути файлов на диске, распознавание
|
||||||
|
или раскладку — реальные пути система по-прежнему читает из qBit API.
|
||||||
|
|
||||||
|
#### Scenario: Имя из контекста передаётся в qBittorrent
|
||||||
|
|
||||||
|
- **WHEN** загрузку добавляют с непустым контекстом, из которого удалось
|
||||||
|
получить имя
|
||||||
|
- **THEN** система передаёт это имя в qBittorrent в параметре `rename`
|
||||||
|
- **AND** имя — короткий читаемый ярлык вида «название (режиссёр, год)»,
|
||||||
|
где режиссёр и год опциональны
|
||||||
|
|
||||||
|
#### Scenario: Имя сохраняется у загрузки
|
||||||
|
|
||||||
|
- **WHEN** при приёме получено непустое отображаемое имя
|
||||||
|
- **THEN** система сохраняет его в `download.display_name` вместе с созданием
|
||||||
|
загрузки
|
||||||
|
- **AND** веб-UI использует его заголовком карточки и страницы загрузки
|
||||||
|
|
||||||
|
#### Scenario: Контекст пуст или имя не получено
|
||||||
|
|
||||||
|
- **WHEN** контекста нет либо ни один способ вывода не дал непустого имени
|
||||||
|
- **THEN** система добавляет загрузку без параметра `rename`
|
||||||
|
- **AND** qBittorrent оставляет собственное имя (из `dn`/торрента)
|
||||||
|
- **AND** `download.display_name` остаётся пустым, а веб-UI берёт заголовок из
|
||||||
|
фолбека (распознанное название или усечённый источник)
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Страницы веб-UI
|
||||||
|
|
||||||
|
Веб-UI SHALL предоставлять страницы: список загрузок с единым окном
|
||||||
|
добавления, **серверными фильтром по группе состояний, поиском и постраничной
|
||||||
|
выдачей (пагинацией)** (`/`), экран ревью одной загрузки (`/review/{id}`)
|
||||||
|
и страницу просмотра одной загрузки (`/download/{id}`) с распознаванием,
|
||||||
|
файлами→раскладкой, историей, блоком информации о торренте и — для сидирующих
|
||||||
|
задач — секцией живой статистики раздачи. Карточки активных (downloading)
|
||||||
|
загрузок в списке SHALL содержать индикатор прогресса. Фильтр, поиск и номер
|
||||||
|
страницы SHALL передаваться GET-параметрами запроса (например `f`, `q`, `page`)
|
||||||
|
и SHALL работать без клиентского JavaScript. Состояние `deleted` SHALL быть
|
||||||
|
скрыто в списке по умолчанию (с переключателем «показать всё»). Механика живого
|
||||||
|
обновления прогресса и наполнение секции раздачи определяются capability
|
||||||
|
`live-status`.
|
||||||
|
|
||||||
|
#### Scenario: Просмотр одной загрузки
|
||||||
|
|
||||||
|
- **WHEN** клиент открывает `GET /download/{id}` существующей загрузки
|
||||||
|
- **THEN** отрисовывается страница с её распознаванием, файлами, раскладкой и
|
||||||
|
историей
|
||||||
|
|
||||||
|
#### Scenario: Прогресс активной загрузки в списке
|
||||||
|
|
||||||
|
- **WHEN** в списке есть загрузка в состоянии `downloading`
|
||||||
|
- **THEN** её карточка содержит индикатор прогресса (прогресс-бар со скоростью
|
||||||
|
и ETA)
|
||||||
|
|
||||||
|
#### Scenario: Удалённые скрыты по умолчанию
|
||||||
|
|
||||||
|
- **WHEN** в списке есть загрузки в состоянии `deleted` и фильтр «показать
|
||||||
|
всё» не включён
|
||||||
|
- **THEN** они не отображаются, но доступны при включённом переключателе
|
||||||
|
|
||||||
|
#### Scenario: Пагинация списка
|
||||||
|
|
||||||
|
- **WHEN** загрузок под текущим фильтром больше, чем помещается на одну
|
||||||
|
страницу, и клиент запрашивает `GET /?page=N`
|
||||||
|
- **THEN** возвращается N-я страница результатов и элементы навигации по
|
||||||
|
страницам, сохраняющие текущие фильтр и поисковый запрос
|
||||||
|
|
||||||
|
#### Scenario: Серверный фильтр и поиск
|
||||||
|
|
||||||
|
- **WHEN** клиент запрашивает список с параметрами фильтра по состоянию и/или
|
||||||
|
строкой поиска (`GET /?f=review&q=дюна`)
|
||||||
|
- **THEN** сервер возвращает только подходящие загрузки (по группе состояний и
|
||||||
|
совпадению строки в названии/infohash/контексте), отфильтрованные на стороне
|
||||||
|
БД, а не на клиенте
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Порядок списка загрузок
|
||||||
|
|
||||||
|
Список загрузок SHALL сортироваться по времени добавления торрента в источник
|
||||||
|
(qBittorrent `added_on`), от новых к старым. Если время добавления в источник
|
||||||
|
неизвестно, для сортировки SHALL использоваться время создания загрузки в
|
||||||
|
jellybit (`created_at`). Порядок MUST быть согласован между страницами
|
||||||
|
пагинации.
|
||||||
|
|
||||||
|
#### Scenario: Сортировка по времени добавления в источник
|
||||||
|
|
||||||
|
- **WHEN** отрисовывается список загрузок, у которых известно время добавления
|
||||||
|
в qBittorrent
|
||||||
|
- **THEN** загрузки идут от недавно добавленных к более старым по этому времени
|
||||||
|
|
||||||
|
#### Scenario: Фолбек порядка без времени источника
|
||||||
|
|
||||||
|
- **WHEN** у загрузки нет сохранённого времени добавления в источник
|
||||||
|
- **THEN** для её позиции в списке используется время создания загрузки в
|
||||||
|
jellybit
|
||||||
|
|
||||||
|
### Requirement: Заголовок загрузки из имени раздачи
|
||||||
|
|
||||||
|
Веб-UI SHALL показывать заголовком загрузки (в карточке списка и в шапке
|
||||||
|
страницы `/download/{id}`) сохранённое отображаемое имя раздачи (`display_name`,
|
||||||
|
переданное в qBittorrent при приёме). Если имя пусто, заголовок SHALL брать
|
||||||
|
распознанное название из плана; если и его нет — сырой источник (`source_ref`),
|
||||||
|
усечённый до одной строки как обычный заголовок. Заголовок MUST NOT занимать
|
||||||
|
несколько строк сырым magnet.
|
||||||
|
|
||||||
|
#### Scenario: Заголовок из имени раздачи
|
||||||
|
|
||||||
|
- **WHEN** у загрузки сохранено отображаемое имя раздачи
|
||||||
|
- **THEN** карточка и страница показывают это имя заголовком
|
||||||
|
|
||||||
|
#### Scenario: Фолбек до распознавания и без имени
|
||||||
|
|
||||||
|
- **WHEN** отображаемого имени нет, но есть распознанное название
|
||||||
|
- **THEN** заголовком служит распознанное название
|
||||||
|
- **AND** если нет ни того, ни другого — заголовком служит усечённый до одной
|
||||||
|
строки сырой источник, а не многострочный magnet
|
||||||
|
|
||||||
|
### Requirement: Матч с записью метабазы ссылкой
|
||||||
|
|
||||||
|
Веб-UI SHALL показывать подтверждённый матч с записью метабазы (TMDB/TVDB/IMDb)
|
||||||
|
как ссылку на эту запись — на странице просмотра `/download/{id}` (блок
|
||||||
|
распознавания) и в блоке «Источник совпадения» экрана ревью. Ссылка SHALL
|
||||||
|
открываться в новой вкладке с `rel="noopener"`. Рядом со ссылкой SHALL быть
|
||||||
|
видны провайдер, идентификатор записи и (при наличии) год.
|
||||||
|
|
||||||
|
#### Scenario: Матч виден ссылкой на странице просмотра
|
||||||
|
|
||||||
|
- **WHEN** у загрузки подтверждён матч с записью метабазы и известен URL записи
|
||||||
|
- **THEN** в блоке распознавания на `/download/{id}` матч показан ссылкой на
|
||||||
|
запись с провайдером и id
|
||||||
|
|
||||||
|
#### Scenario: URL записи неизвестен
|
||||||
|
|
||||||
|
- **WHEN** матч подтверждён (например, id задан вручную), но канонический URL
|
||||||
|
записи построить нельзя
|
||||||
|
- **THEN** матч показывается текстом (провайдер и id) без ссылки, страница не
|
||||||
|
ломается
|
||||||
|
|
||||||
|
### Requirement: Блок информации о торренте
|
||||||
|
|
||||||
|
Страница `/download/{id}` SHALL содержать блок «Информация о торренте» с полным
|
||||||
|
исходным источником (magnet/URL/`.torrent`-ссылка) и infohash с кнопкой
|
||||||
|
копирования. Сырой источник MUST выноситься в этот блок, а не в заголовок.
|
||||||
|
|
||||||
|
#### Scenario: Полный источник в отдельном блоке
|
||||||
|
|
||||||
|
- **WHEN** клиент открывает `GET /download/{id}`
|
||||||
|
- **THEN** полный источник (magnet) и infohash показаны в блоке «Информация о
|
||||||
|
торренте», а заголовок страницы остаётся человекочитаемым именем
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
## 1. Персистентность имени раздачи и времени добавления (БД + ingest + worker)
|
||||||
|
|
||||||
|
- [x] 1.1 Миграция `0005` (goose): `download.display_name TEXT NOT NULL DEFAULT ''` и `download.source_added_at` (nullable timestamp), обратная миграция (down)
|
||||||
|
- [x] 1.2 Добавить поля `DisplayName` и `SourceAddedAt` в `store.Download`; писать `display_name` в `CreateDownload`; метод однократной записи `source_added_at` с SQL-гардом `WHERE source_added_at IS NULL`; экспортировать хелпер форматирования времени в формате `sqliteTimeLayout` (UTC)
|
||||||
|
- [x] 1.3 В `internal/ingest` заполнять `DisplayName` выведенным именем `rename` при создании загрузки; в `discover.adopt()` — `display_name = t.Name` для усыновлённых торрентов
|
||||||
|
- [x] 1.4 В `internal/worker` персистить `source_added_at` из `qbt.Torrent.AddedOn` в цикле `Poll` сразу после `byHash`-сопоставления (до `reconcile`, для любого класса состояния) и в `adopt()`; формат — `time.Unix(...).UTC().Format(sqliteTimeLayout)`; однократно (гард по `d.SourceAddedAt` + SQL IS NULL); под `w.mu`
|
||||||
|
- [x] 1.5 Обновить ER-схему `docs/specs/database.md` (колонки `display_name`, `source_added_at`)
|
||||||
|
- [x] 1.6 Тесты: ingest сохраняет `display_name`; adopt пишет `display_name` из имени торрента; worker пишет `source_added_at` однократно и в формате `created_at`; пустой контекст → пустое имя
|
||||||
|
|
||||||
|
## 2. Серверные фильтр, поиск, сортировка и пагинация списка (store)
|
||||||
|
|
||||||
|
- [x] 2.1 Вынести маппинг состояние↔группа фильтра в единый источник истины (используется и в UI, и в запросе); учесть `all` с исключением `deleted` и осознанно — группу `other` (cancelled/reverted/deleted) без своего чипа
|
||||||
|
- [x] 2.2 Метод `store.ListDownloadsPage(ctx, ListFilter)` → страница + общий count: `WHERE` по группе состояний, `deleted` только при «показать всё», поиск `LIKE`/`COLLATE NOCASE` по `source_ref`/`display_name`/`context`/`infohash`, сортировка `ORDER BY COALESCE(source_added_at, created_at) DESC, id DESC`, `LIMIT`/`OFFSET`
|
||||||
|
- [x] 2.3 Тесты `store`: фильтр по группе, поиск, скрытие `deleted` по умолчанию, порядок по времени добавления с фолбеком + тай-брейк по id при равных метках, границы пагинации и count
|
||||||
|
|
||||||
|
## 3. Список на главной (httpapi + шаблоны)
|
||||||
|
|
||||||
|
- [x] 3.1 Разбор GET-параметров `f`/`q`/`page`/`all` в `handleIndex` (клэмп `page`: `<1`/нечисловой → 1, за пределом → пустая страница), константа размера страницы (25), вызов `ListDownloadsPage`; расширить интерфейс `Reader` методом `ListDownloadsPage`
|
||||||
|
- [x] 3.2 View-модель списка: элементы страницы + данные пагинации + текущие фильтр/поиск; удалить ставшие мёртвыми `SearchText` и (если больше не нужен) `Group` из `downloadView`
|
||||||
|
- [x] 3.3 `index.html`: чипы-фильтр и «показать всё» как ссылки (сохраняют `q`), форма поиска (GET), навигация по страницам (сохраняет `f`/`q`); серверный рендер пустого состояния («ничего не найдено» при активных фильтре/поиске, иначе «пока пусто»)
|
||||||
|
- [x] 3.4 Убрать клиентскую JS-фильтрацию из `app.js` (`applyFilters`/`setFilter` + `DOMContentLoaded`-хук), оставить `copyHash` и `<details>`; сохранить живой прогресс активных карточек
|
||||||
|
- [x] 3.5 Заголовок карточки — фолбек-цепочка `display_name` → распознанное → усечённый источник (одна строка)
|
||||||
|
- [x] 3.6 Тесты `httpapi`: параметры списка → корректная выборка/пагинация; заголовок из `display_name` и фолбеки
|
||||||
|
|
||||||
|
## 4. Страница загрузки: имя, матч-ссылка, блок торрента (httpapi + шаблоны)
|
||||||
|
|
||||||
|
- [x] 4.1 Заголовок/шапка `/download/{id}` — та же фолбек-цепочка заголовка
|
||||||
|
- [x] 4.2 Хелпер `providerURL(provider, id, mediaType)` (tmdb: movie/tv; tvdb: медиазависимый путь; imdb: /title) + выбор URL матча: URL выбранного кандидата только если его provider+id == эффективным `rd.Provider`/`rd.ProviderID`, иначе построенный; нет надёжного URL → без ссылки
|
||||||
|
- [x] 4.3 «Распознано как»: показать матч ссылкой на запись (провайдер, id, год); при отсутствии URL — текст без ссылки
|
||||||
|
- [x] 4.4 Блок «Информация о торренте»: полный источник (magnet) + infohash с копированием
|
||||||
|
- [x] 4.5 Тесты `httpapi`: матч-ссылка при наличии/отсутствии URL; расхождение выбранного кандидата и эффективного provider_id (ссылка ведёт на эффективную запись); блок торрента
|
||||||
|
|
||||||
|
## 5. Ревью: матч-ссылка (httpapi + шаблон)
|
||||||
|
|
||||||
|
- [x] 5.1 В блоке «Источник совпадения» строку «Выбрано» показывать ссылкой на запись (та же логика выбора URL, что в 4.2)
|
||||||
|
|
||||||
|
## 6. Полировка вёрстки (CSS/шаблоны)
|
||||||
|
|
||||||
|
- [x] 6.1 «Распознано как»: снять `max-width` у списка полей — блок на всю ширину
|
||||||
|
- [x] 6.2 Layout-виджет: приклеить стрелку к строке файла-источника (источник и цель строго друг под другом)
|
||||||
|
- [x] 6.3 Проверить отсутствие инлайн-`<style>` с хардкодом цветов в изменённых шаблонах (правки — в `jellybit.css`)
|
||||||
|
|
||||||
|
## 7. Проверка и ревью
|
||||||
|
|
||||||
|
- [x] 7.1 `task test` и `task lint` зелёные
|
||||||
|
- [x] 7.2 `openspec validate web-ui-list-detail --strict`
|
||||||
|
- [ ] 7.3 Ручная проверка: список с фильтром/поиском/пагинацией, живой прогресс не сбрасывается; заголовки; матч-ссылки; блок торрента; полировка
|
||||||
|
- [x] 7.4 Ревью кода перед archive
|
||||||
@@ -13,7 +13,9 @@ qBittorrent. Capability описывает вывод человекочитае
|
|||||||
При добавлении загрузки в qBittorrent система SHALL выводить из контекста
|
При добавлении загрузки в qBittorrent система SHALL выводить из контекста
|
||||||
загрузки человекочитаемое отображаемое имя и передавать его в qBittorrent
|
загрузки человекочитаемое отображаемое имя и передавать его в qBittorrent
|
||||||
(параметр `rename` API `/torrents/add`), чтобы задача в списке qBit не
|
(параметр `rename` API `/torrents/add`), чтобы задача в списке qBit не
|
||||||
показывалась безликим `dn` magnet-ссылки.
|
показывалась безликим `dn` magnet-ссылки. Это же имя система SHALL **сохранять
|
||||||
|
у загрузки** (`download.display_name`) для последующего показа заголовком в
|
||||||
|
веб-UI.
|
||||||
|
|
||||||
Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и
|
Имя SHALL быть коротким читаемым ярлыком (название, опционально режиссёр и
|
||||||
год; для сериала — номер сезона, если он определён), а не куском сырого
|
год; для сериала — номер сезона, если он определён), а не куском сырого
|
||||||
@@ -23,9 +25,9 @@ SHALL обрезаться по ограничению длины.
|
|||||||
Вывод имени SHALL выполняться синхронно перед отдачей источника в
|
Вывод имени SHALL выполняться синхронно перед отдачей источника в
|
||||||
qBittorrent (параметр `rename` действует только в момент добавления).
|
qBittorrent (параметр `rename` действует только в момент добавления).
|
||||||
|
|
||||||
Отображаемое имя SHALL влиять только на отображение в qBittorrent и SHALL
|
Отображаемое имя SHALL влиять только на отображение (в qBittorrent и как
|
||||||
NOT влиять на пути файлов на диске, распознавание или раскладку — реальные
|
заголовок в веб-UI) и SHALL NOT влиять на пути файлов на диске, распознавание
|
||||||
пути система по-прежнему читает из qBit API.
|
или раскладку — реальные пути система по-прежнему читает из qBit API.
|
||||||
|
|
||||||
#### Scenario: Имя из контекста передаётся в qBittorrent
|
#### Scenario: Имя из контекста передаётся в qBittorrent
|
||||||
|
|
||||||
@@ -35,11 +37,20 @@ NOT влиять на пути файлов на диске, распознав
|
|||||||
- **AND** имя — короткий читаемый ярлык вида «название (режиссёр, год)»,
|
- **AND** имя — короткий читаемый ярлык вида «название (режиссёр, год)»,
|
||||||
где режиссёр и год опциональны
|
где режиссёр и год опциональны
|
||||||
|
|
||||||
|
#### Scenario: Имя сохраняется у загрузки
|
||||||
|
|
||||||
|
- **WHEN** при приёме получено непустое отображаемое имя
|
||||||
|
- **THEN** система сохраняет его в `download.display_name` вместе с созданием
|
||||||
|
загрузки
|
||||||
|
- **AND** веб-UI использует его заголовком карточки и страницы загрузки
|
||||||
|
|
||||||
#### Scenario: Контекст пуст или имя не получено
|
#### Scenario: Контекст пуст или имя не получено
|
||||||
|
|
||||||
- **WHEN** контекста нет либо ни один способ вывода не дал непустого имени
|
- **WHEN** контекста нет либо ни один способ вывода не дал непустого имени
|
||||||
- **THEN** система добавляет загрузку без параметра `rename`
|
- **THEN** система добавляет загрузку без параметра `rename`
|
||||||
- **AND** qBittorrent оставляет собственное имя (из `dn`/торрента)
|
- **AND** qBittorrent оставляет собственное имя (из `dn`/торрента)
|
||||||
|
- **AND** `download.display_name` остаётся пустым, а веб-UI берёт заголовок из
|
||||||
|
фолбека (распознанное название или усечённый источник)
|
||||||
|
|
||||||
### Requirement: Вывод имени через LLM со структурированным выводом
|
### Requirement: Вывод имени через LLM со структурированным выводом
|
||||||
|
|
||||||
|
|||||||
@@ -88,13 +88,17 @@ self-hosted шрифтов, единая дизайн-система (`jellybit.
|
|||||||
### Requirement: Страницы веб-UI
|
### Requirement: Страницы веб-UI
|
||||||
|
|
||||||
Веб-UI SHALL предоставлять страницы: список загрузок с единым окном
|
Веб-UI SHALL предоставлять страницы: список загрузок с единым окном
|
||||||
добавления и фильтром/поиском (`/`), экран ревью одной загрузки (`/review/{id}`)
|
добавления, **серверными фильтром по группе состояний, поиском и постраничной
|
||||||
|
выдачей (пагинацией)** (`/`), экран ревью одной загрузки (`/review/{id}`)
|
||||||
и страницу просмотра одной загрузки (`/download/{id}`) с распознаванием,
|
и страницу просмотра одной загрузки (`/download/{id}`) с распознаванием,
|
||||||
файлами→раскладкой, историей и — для сидирующих задач — секцией живой
|
файлами→раскладкой, историей, блоком информации о торренте и — для сидирующих
|
||||||
статистики раздачи. Карточки активных (downloading) загрузок в списке SHALL
|
задач — секцией живой статистики раздачи. Карточки активных (downloading)
|
||||||
содержать индикатор прогресса. Состояние `deleted` SHALL быть скрыто в списке
|
загрузок в списке SHALL содержать индикатор прогресса. Фильтр, поиск и номер
|
||||||
по умолчанию (с переключателем «показать всё»). Механика живого обновления
|
страницы SHALL передаваться GET-параметрами запроса (например `f`, `q`, `page`)
|
||||||
прогресса и наполнение секции раздачи определяются capability `live-status`.
|
и SHALL работать без клиентского JavaScript. Состояние `deleted` SHALL быть
|
||||||
|
скрыто в списке по умолчанию (с переключателем «показать всё»). Механика живого
|
||||||
|
обновления прогресса и наполнение секции раздачи определяются capability
|
||||||
|
`live-status`.
|
||||||
|
|
||||||
#### Scenario: Просмотр одной загрузки
|
#### Scenario: Просмотр одной загрузки
|
||||||
|
|
||||||
@@ -114,6 +118,95 @@ self-hosted шрифтов, единая дизайн-система (`jellybit.
|
|||||||
всё» не включён
|
всё» не включён
|
||||||
- **THEN** они не отображаются, но доступны при включённом переключателе
|
- **THEN** они не отображаются, но доступны при включённом переключателе
|
||||||
|
|
||||||
|
#### Scenario: Пагинация списка
|
||||||
|
|
||||||
|
- **WHEN** загрузок под текущим фильтром больше, чем помещается на одну
|
||||||
|
страницу, и клиент запрашивает `GET /?page=N`
|
||||||
|
- **THEN** возвращается N-я страница результатов и элементы навигации по
|
||||||
|
страницам, сохраняющие текущие фильтр и поисковый запрос
|
||||||
|
|
||||||
|
#### Scenario: Серверный фильтр и поиск
|
||||||
|
|
||||||
|
- **WHEN** клиент запрашивает список с параметрами фильтра по состоянию и/или
|
||||||
|
строкой поиска (`GET /?f=review&q=дюна`)
|
||||||
|
- **THEN** сервер возвращает только подходящие загрузки (по группе состояний и
|
||||||
|
совпадению строки в названии/infohash/контексте), отфильтрованные на стороне
|
||||||
|
БД, а не на клиенте
|
||||||
|
|
||||||
|
### Requirement: Порядок списка загрузок
|
||||||
|
|
||||||
|
Список загрузок SHALL сортироваться по времени добавления торрента в источник
|
||||||
|
(qBittorrent `added_on`), от новых к старым. Если время добавления в источник
|
||||||
|
неизвестно, для сортировки SHALL использоваться время создания загрузки в
|
||||||
|
jellybit (`created_at`). Порядок MUST быть согласован между страницами
|
||||||
|
пагинации.
|
||||||
|
|
||||||
|
#### Scenario: Сортировка по времени добавления в источник
|
||||||
|
|
||||||
|
- **WHEN** отрисовывается список загрузок, у которых известно время добавления
|
||||||
|
в qBittorrent
|
||||||
|
- **THEN** загрузки идут от недавно добавленных к более старым по этому времени
|
||||||
|
|
||||||
|
#### Scenario: Фолбек порядка без времени источника
|
||||||
|
|
||||||
|
- **WHEN** у загрузки нет сохранённого времени добавления в источник
|
||||||
|
- **THEN** для её позиции в списке используется время создания загрузки в
|
||||||
|
jellybit
|
||||||
|
|
||||||
|
### Requirement: Заголовок загрузки из имени раздачи
|
||||||
|
|
||||||
|
Веб-UI SHALL показывать заголовком загрузки (в карточке списка и в шапке
|
||||||
|
страницы `/download/{id}`) сохранённое отображаемое имя раздачи (`display_name`,
|
||||||
|
переданное в qBittorrent при приёме). Если имя пусто, заголовок SHALL брать
|
||||||
|
распознанное название из плана; если и его нет — сырой источник (`source_ref`),
|
||||||
|
усечённый до одной строки как обычный заголовок. Заголовок MUST NOT занимать
|
||||||
|
несколько строк сырым magnet.
|
||||||
|
|
||||||
|
#### Scenario: Заголовок из имени раздачи
|
||||||
|
|
||||||
|
- **WHEN** у загрузки сохранено отображаемое имя раздачи
|
||||||
|
- **THEN** карточка и страница показывают это имя заголовком
|
||||||
|
|
||||||
|
#### Scenario: Фолбек до распознавания и без имени
|
||||||
|
|
||||||
|
- **WHEN** отображаемого имени нет, но есть распознанное название
|
||||||
|
- **THEN** заголовком служит распознанное название
|
||||||
|
- **AND** если нет ни того, ни другого — заголовком служит усечённый до одной
|
||||||
|
строки сырой источник, а не многострочный magnet
|
||||||
|
|
||||||
|
### Requirement: Матч с записью метабазы ссылкой
|
||||||
|
|
||||||
|
Веб-UI SHALL показывать подтверждённый матч с записью метабазы (TMDB/TVDB/IMDb)
|
||||||
|
как ссылку на эту запись — на странице просмотра `/download/{id}` (блок
|
||||||
|
распознавания) и в блоке «Источник совпадения» экрана ревью. Ссылка SHALL
|
||||||
|
открываться в новой вкладке с `rel="noopener"`. Рядом со ссылкой SHALL быть
|
||||||
|
видны провайдер, идентификатор записи и (при наличии) год.
|
||||||
|
|
||||||
|
#### Scenario: Матч виден ссылкой на странице просмотра
|
||||||
|
|
||||||
|
- **WHEN** у загрузки подтверждён матч с записью метабазы и известен URL записи
|
||||||
|
- **THEN** в блоке распознавания на `/download/{id}` матч показан ссылкой на
|
||||||
|
запись с провайдером и id
|
||||||
|
|
||||||
|
#### Scenario: URL записи неизвестен
|
||||||
|
|
||||||
|
- **WHEN** матч подтверждён (например, id задан вручную), но канонический URL
|
||||||
|
записи построить нельзя
|
||||||
|
- **THEN** матч показывается текстом (провайдер и id) без ссылки, страница не
|
||||||
|
ломается
|
||||||
|
|
||||||
|
### Requirement: Блок информации о торренте
|
||||||
|
|
||||||
|
Страница `/download/{id}` SHALL содержать блок «Информация о торренте» с полным
|
||||||
|
исходным источником (magnet/URL/`.torrent`-ссылка) и infohash с кнопкой
|
||||||
|
копирования. Сырой источник MUST выноситься в этот блок, а не в заголовок.
|
||||||
|
|
||||||
|
#### Scenario: Полный источник в отдельном блоке
|
||||||
|
|
||||||
|
- **WHEN** клиент открывает `GET /download/{id}`
|
||||||
|
- **THEN** полный источник (magnet) и infohash показаны в блоке «Информация о
|
||||||
|
торренте», а заголовок страницы остаётся человекочитаемым именем
|
||||||
|
|
||||||
### Requirement: Действия соответствуют состоянию
|
### Requirement: Действия соответствуют состоянию
|
||||||
|
|
||||||
Каждая страница SHALL показывать только действия, допустимые в текущем
|
Каждая страница SHALL показывать только действия, допустимые в текущем
|
||||||
|
|||||||
@@ -261,12 +261,19 @@ details.spoiler{margin-top:var(--sp-3)}
|
|||||||
.toggle-line{display:flex;align-items:center;gap:8px;font-size:var(--fs-sm);color:var(--text-dim);
|
.toggle-line{display:flex;align-items:center;gap:8px;font-size:var(--fs-sm);color:var(--text-dim);
|
||||||
margin-bottom:var(--sp-4)}
|
margin-bottom:var(--sp-4)}
|
||||||
.toggle-line label{display:inline-flex;align-items:center;gap:7px;cursor:pointer}
|
.toggle-line label{display:inline-flex;align-items:center;gap:7px;cursor:pointer}
|
||||||
|
.toggle-line a{display:inline-flex;align-items:center;gap:7px;color:var(--text-dim)}
|
||||||
|
.toggle-line a:hover{color:var(--text)}
|
||||||
|
|
||||||
/* пагинация */
|
/* пагинация */
|
||||||
.pager{display:flex;align-items:center;justify-content:center;gap:var(--sp-2);
|
.pager{display:flex;align-items:center;justify-content:center;gap:var(--sp-2);
|
||||||
margin-top:var(--sp-5);font-size:var(--fs-sm);color:var(--text-dim)}
|
margin-top:var(--sp-5);font-size:var(--fs-sm);color:var(--text-dim)}
|
||||||
.pager .btn{min-width:36px}
|
.pager .btn{min-width:36px}
|
||||||
|
|
||||||
|
/* блок «Информация о торренте» — сырой источник + копирование */
|
||||||
|
.src-block{display:flex;align-items:flex-start;gap:var(--sp-2);margin-top:var(--sp-3)}
|
||||||
|
.src-full{flex:1;font-family:var(--mono);font-size:var(--fs-xs);color:var(--text-dim);
|
||||||
|
word-break:break-all}
|
||||||
|
|
||||||
/* ---------- 10. Секция ревью ---------- */
|
/* ---------- 10. Секция ревью ---------- */
|
||||||
.section{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-lg);
|
.section{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-lg);
|
||||||
padding:var(--sp-5);margin-bottom:var(--sp-4);box-shadow:var(--shadow-sm)}
|
padding:var(--sp-5);margin-bottom:var(--sp-4);box-shadow:var(--shadow-sm)}
|
||||||
@@ -338,9 +345,11 @@ table.tbl tr:last-child td{border-bottom:none}
|
|||||||
table.tbl tr.ignored td{opacity:.45}
|
table.tbl tr.ignored td{opacity:.45}
|
||||||
table.tbl tr.invalid{background:var(--st-err-bg)}
|
table.tbl tr.invalid{background:var(--st-err-bg)}
|
||||||
table.tbl .fname{font-family:var(--mono);font-size:var(--fs-xs);word-break:break-all}
|
table.tbl .fname{font-family:var(--mono);font-size:var(--fs-xs);word-break:break-all}
|
||||||
table.tbl .link-to{display:flex;align-items:baseline;gap:6px;margin-top:5px;
|
/* Стрелка приклеена к строке источника — источник и цель идут строго друг под
|
||||||
font-family:var(--mono);font-size:var(--fs-xs);flex-wrap:wrap}
|
другом (цель не смещена ведущей стрелкой). */
|
||||||
table.tbl .link-to .arr{color:var(--text-faint);flex:none}
|
table.tbl .fname .arr{color:var(--text-faint)}
|
||||||
|
table.tbl .link-to{margin-top:5px;font-family:var(--mono);font-size:var(--fs-xs);
|
||||||
|
word-break:break-all}
|
||||||
table.tbl .link-to .dir{color:var(--text-faint)}
|
table.tbl .link-to .dir{color:var(--text-faint)}
|
||||||
table.tbl .link-to .src{color:var(--st-ok)}
|
table.tbl .link-to .src{color:var(--st-ok)}
|
||||||
table.tbl .link-to .no-link{color:var(--text-faint);font-style:italic;font-family:var(--font)}
|
table.tbl .link-to .no-link{color:var(--text-faint);font-style:italic;font-family:var(--font)}
|
||||||
|
|||||||
+2
-33
@@ -40,36 +40,5 @@ function flashCopy(btn, ok) {
|
|||||||
setTimeout(() => { btn.textContent = old; btn.classList.remove('copied'); }, 1400);
|
setTimeout(() => { btn.textContent = old; btn.classList.remove('copied'); }, 1400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Фильтр/поиск по списку загрузок (по уже отрисованным карточкам) ---
|
// Фильтр, поиск и пагинация списка — серверные (GET-параметры f/q/page/all),
|
||||||
|
// работают без JS. Здесь клиентской фильтрации нет намеренно.
|
||||||
let activeFilter = 'all';
|
|
||||||
|
|
||||||
function setFilter(el) {
|
|
||||||
document.querySelectorAll('#chips .chip').forEach((c) => c.classList.remove('active'));
|
|
||||||
el.classList.add('active');
|
|
||||||
activeFilter = el.dataset.f;
|
|
||||||
applyFilters();
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFilters() {
|
|
||||||
const searchEl = document.querySelector('#list-toolbar input[type=search]');
|
|
||||||
const q = (searchEl ? searchEl.value : '').toLowerCase().trim();
|
|
||||||
const showAllEl = document.getElementById('showAll');
|
|
||||||
const showAll = showAllEl ? showAllEl.checked : false;
|
|
||||||
let visible = 0;
|
|
||||||
document.querySelectorAll('#list .card').forEach((card) => {
|
|
||||||
const grp = card.dataset.group;
|
|
||||||
const isDeleted = card.dataset.deleted === '1';
|
|
||||||
let ok = true;
|
|
||||||
if (isDeleted && !showAll) ok = false;
|
|
||||||
if (ok && activeFilter !== 'all' && grp !== activeFilter) ok = false;
|
|
||||||
if (ok && q && !(card.dataset.text || '').includes(q)) ok = false;
|
|
||||||
card.style.display = ok ? '' : 'none';
|
|
||||||
if (ok) visible += 1;
|
|
||||||
});
|
|
||||||
const empty = document.getElementById('empty');
|
|
||||||
if (empty) empty.style.display = visible ? 'none' : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Применить начальный фильтр (скрыть удалённые) после загрузки.
|
|
||||||
document.addEventListener('DOMContentLoaded', applyFilters);
|
|
||||||
|
|||||||
@@ -45,10 +45,10 @@
|
|||||||
<div class="meta-body">
|
<div class="meta-body">
|
||||||
<p class="h">{{.RecTitle}}</p>
|
<p class="h">{{.RecTitle}}</p>
|
||||||
{{if .OriginalTitle}}<p class="o">{{.OriginalTitle}}</p>{{end}}
|
{{if .OriginalTitle}}<p class="o">{{.OriginalTitle}}</p>{{end}}
|
||||||
<dl class="kv" style="max-width:360px">
|
<dl class="kv">
|
||||||
<dt>Тип</dt><dd>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</dd>
|
<dt>Тип</dt><dd>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</dd>
|
||||||
{{if .Year}}<dt>Год</dt><dd>{{.Year}}</dd>{{end}}
|
{{if .Year}}<dt>Год</dt><dd>{{.Year}}</dd>{{end}}
|
||||||
<dt>База</dt><dd>{{if .Provider}}{{.Provider}} · <span class="mono">{{.ProviderID}}</span>{{else if .NoBase}}без базы{{else}}—{{end}}</dd>
|
<dt>База</dt><dd>{{if .Provider}}{{if .MatchURL}}<a class="ext-link" href="{{.MatchURL}}" target="_blank" rel="noopener">{{.Provider}} · {{.ProviderID}} ↗</a>{{else}}{{.Provider}} · <span class="mono">{{.ProviderID}}</span>{{end}}{{else if .NoBase}}без базы{{else}}—{{end}}</dd>
|
||||||
{{if .Confidence}}<dt>Уверенность</dt><dd class="mono">{{.Confidence}}</dd>{{end}}
|
{{if .Confidence}}<dt>Уверенность</dt><dd class="mono">{{.Confidence}}</dd>{{end}}
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,11 +83,26 @@
|
|||||||
|
|
||||||
<!-- Контекст -->
|
<!-- Контекст -->
|
||||||
{{if .Context}}
|
{{if .Context}}
|
||||||
<div class="section" style="margin-bottom:0">
|
<div class="section">
|
||||||
<div class="section-head"><h2>Контекст</h2><span class="hint">сообщение при добавлении</span></div>
|
<div class="section-head"><h2>Контекст</h2><span class="hint">сообщение при добавлении</span></div>
|
||||||
<p style="margin:0;color:var(--text-dim)">«{{.Context}}»</p>
|
<p style="margin:0;color:var(--text-dim)">«{{.Context}}»</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
<!-- Информация о торренте -->
|
||||||
|
<div class="section" style="margin-bottom:0">
|
||||||
|
<div class="section-head"><h2>Информация о торренте</h2><span class="hint">сырой источник и infohash</span></div>
|
||||||
|
<dl class="kv">
|
||||||
|
<dt>Тип источника</dt><dd>{{.SourceType}}</dd>
|
||||||
|
{{if .Infohash}}<dt>infohash</dt><dd class="mono">{{.Infohash}}</dd>{{end}}
|
||||||
|
</dl>
|
||||||
|
{{if .SourceFull}}
|
||||||
|
<div class="src-block">
|
||||||
|
<code class="src-full">{{.SourceFull}}</code>
|
||||||
|
<button class="copy-btn" type="button" onclick="copyHash(this,'{{.SourceFull}}')">⧉ копировать</button>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+22
-17
@@ -28,30 +28,31 @@
|
|||||||
<p class="add-hint">Можно вставить целиком сообщение трекер-бота — magnet и контекст разберутся автоматически.</p>
|
<p class="add-hint">Можно вставить целиком сообщение трекер-бота — magnet и контекст разберутся автоматически.</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{{if .Downloads}}
|
{{if or .Downloads .Searching}}
|
||||||
<!-- Панель фильтров -->
|
<!-- Панель фильтров (серверные: фильтр/поиск/пагинация в query) -->
|
||||||
<div class="toolbar" id="list-toolbar">
|
<div class="toolbar" id="list-toolbar">
|
||||||
<div class="search">
|
<form class="search" method="get" action="/">
|
||||||
<span class="ico">⌕</span>
|
<span class="ico">⌕</span>
|
||||||
<input class="input" type="search" placeholder="Поиск по названию или infohash…" oninput="applyFilters()">
|
<input class="input" type="search" name="q" value="{{.Query}}"
|
||||||
</div>
|
placeholder="Поиск по названию или infohash…">
|
||||||
|
{{if ne .Filter "all"}}<input type="hidden" name="f" value="{{.Filter}}">{{end}}
|
||||||
|
{{if .ShowAll}}<input type="hidden" name="all" value="1">{{end}}
|
||||||
|
</form>
|
||||||
<div class="filter-chips" id="chips">
|
<div class="filter-chips" id="chips">
|
||||||
<button class="chip active" type="button" data-f="all" onclick="setFilter(this)">Все</button>
|
{{range .Chips}}<a class="chip{{if .Active}} active{{end}}" href="{{.URL}}">{{.Label}}</a>{{end}}
|
||||||
<button class="chip" type="button" data-f="review" onclick="setFilter(this)">Ждут меня</button>
|
|
||||||
<button class="chip" type="button" data-f="active" onclick="setFilter(this)">В работе</button>
|
|
||||||
<button class="chip" type="button" data-f="done" onclick="setFilter(this)">Готово</button>
|
|
||||||
<button class="chip" type="button" data-f="problem" onclick="setFilter(this)">Проблемы</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toggle-line">
|
<div class="toggle-line">
|
||||||
<label><input type="checkbox" id="showAll" onchange="applyFilters()"> Показать всё, включая удалённые</label>
|
<a href="{{.ShowURL}}">{{if .ShowAll}}☑{{else}}☐{{end}} Показать всё, включая удалённые</a>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Downloads}}
|
||||||
<!-- Список карточек -->
|
<!-- Список карточек -->
|
||||||
<div id="list">
|
<div id="list">
|
||||||
{{range .Downloads}}
|
{{range .Downloads}}
|
||||||
<article class="card" data-group="{{.Group}}" data-text="{{.SearchText}}"{{if .Deleted}} data-deleted="1"{{end}}>
|
<article class="card">
|
||||||
<div class="card-top">
|
<div class="card-top">
|
||||||
<div>
|
<div>
|
||||||
<div class="card-title"><a href="/download/{{.ID}}" style="color:inherit">{{.Title}}</a></div>
|
<div class="card-title"><a href="/download/{{.ID}}" style="color:inherit">{{.Title}}</a></div>
|
||||||
@@ -88,14 +89,18 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="empty-state" id="empty" style="display:none">
|
{{if gt .Pages 1}}
|
||||||
<div class="big">🍿</div>
|
<nav class="pager">
|
||||||
Ничего не найдено. Поменяй фильтр или запрос.
|
{{if .PrevURL}}<a class="btn btn-sm" href="{{.PrevURL}}">←</a>{{end}}
|
||||||
</div>
|
{{range .PageLinks}}<a class="btn btn-sm{{if .Active}} btn-primary{{end}}" href="{{.URL}}">{{.Num}}</a>{{end}}
|
||||||
|
{{if .NextURL}}<a class="btn btn-sm" href="{{.NextURL}}">→</a>{{end}}
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="big">🍿</div>
|
<div class="big">🍿</div>
|
||||||
Пока пусто. Добавь первую загрузку выше.
|
{{if .Searching}}Ничего не найдено. Поменяй фильтр или запрос.{{else}}Пока пусто. Добавь первую загрузку выше.{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,8 @@
|
|||||||
<tr class="{{if $f.Ignored}}ignored{{end}}">
|
<tr class="{{if $f.Ignored}}ignored{{end}}">
|
||||||
<td class="cell-num">{{add $i 1}}</td>
|
<td class="cell-num">{{add $i 1}}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="fname">{{$f.Src}}</div>
|
<div class="fname">{{$f.Src}} <span class="arr">→</span></div>
|
||||||
<div class="link-to">
|
<div class="link-to">
|
||||||
<span class="arr">→</span>
|
|
||||||
{{if $f.Linked}}<span class="src">{{$f.Dst}}</span>{{else}}<span class="no-link">не разложен</span>{{end}}
|
{{if $f.Linked}}<span class="src">{{$f.Dst}}</span>{{else}}<span class="no-link">не разложен</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="section-head"><h2>Источник совпадения</h2><span class="hint">выбери базу или оставь распознавание нейронкой</span></div>
|
<div class="section-head"><h2>Источник совпадения</h2><span class="hint">выбери базу или оставь распознавание нейронкой</span></div>
|
||||||
|
|
||||||
{{if .Provider}}<p style="margin:0 0 var(--sp-3)">Выбрано: <b>{{.Provider}}</b> <span class="mono">{{.ProviderID}}</span></p>
|
{{if .Provider}}<p style="margin:0 0 var(--sp-3)">Выбрано: <b>{{.Provider}}</b> {{if .MatchURL}}<a class="ext-link" href="{{.MatchURL}}" target="_blank" rel="noopener">{{.ProviderID}} ↗</a>{{else}}<span class="mono">{{.ProviderID}}</span>{{end}}</p>
|
||||||
{{else if .NoBase}}<p style="margin:0 0 var(--sp-3)">Выбрано: <b>без базы</b> <span class="faint">(тег папки не ставится)</span></p>
|
{{else if .NoBase}}<p style="margin:0 0 var(--sp-3)">Выбрано: <b>без базы</b> <span class="faint">(тег папки не ставится)</span></p>
|
||||||
{{else}}<p style="margin:0 0 var(--sp-3)"><span class="faint">Матч не подтверждён — выбери кандидата, введи id или «без базы».</span></p>{{end}}
|
{{else}}<p style="margin:0 0 var(--sp-3)"><span class="faint">Матч не подтверждён — выбери кандидата, введи id или «без базы».</span></p>{{end}}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user