diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 3804930..f57af2f 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -200,6 +200,7 @@ type pageLink struct { type downloadView struct { ID string Title string // отображаемый заголовок карточки + MediaType string // тип контента для значка строки списка (movie/series; пусто — не распознан) State string Error string Terminal bool @@ -652,6 +653,7 @@ func (s *server) toView(d store.Download, now time.Time) downloadView { v := downloadView{ ID: d.ID, Title: downloadTitle(d), + MediaType: d.RecMediaType.String, // пусто, если распознавания/типа ещё нет State: state, Error: d.ErrorMsg.String, Terminal: d.State.IsTerminal(), diff --git a/internal/store/download.go b/internal/store/download.go index 15b84f5..cf33134 100644 --- a/internal/store/download.go +++ b/internal/store/download.go @@ -204,6 +204,11 @@ type Download struct { // Заполняется только листингом ListDownloadsPage для фолбека заголовка; в // прочих выборках остаётся пустым. RecTitle sql.NullString `db:"rec_title"` + + // RecMediaType — тип текущей попытки распознавания (movie/series; LEFT JOIN + // recognition). Как и RecTitle, заполняется только листингом + // ListDownloadsPage — для значка типа в строке списка; в прочих выборках пуст. + RecMediaType sql.NullString `db:"rec_media_type"` } // HashList — все хеши загрузки списком (для сопоставления с qBittorrent). diff --git a/internal/store/list.go b/internal/store/list.go index ed59c7f..d64d598 100644 --- a/internal/store/list.go +++ b/internal/store/list.go @@ -11,7 +11,7 @@ import ( type StateGroup string const ( - GroupAll StateGroup = "all" // все, кроме deleted (если не IncludeDeleted) + GroupAll StateGroup = "all" // все, кроме скрытых терминальных (deleted/cancelled), если не IncludeDeleted GroupReview StateGroup = "review" // ждут человека GroupActive StateGroup = "active" // в работе GroupDone StateGroup = "done" // готово @@ -21,8 +21,9 @@ const ( // statesInGroup возвращает состояния группы фильтра. nil — группа не сужает // выборку по состоянию (all/неизвестная): состояния перечисляются здесь и // нигде больше, чтобы UI и запрос не разъезжались. Терминальные cancelled/ -// reverted/deleted своей группы (чипа) не имеют — под all они видны (deleted — -// только при IncludeDeleted), точечно к ним не фильтруем (см. tasks 2.1). +// reverted/deleted своей группы (чипа) не имеют. Под all скрытые терминальные +// (deleted и cancelled — шум в ленте) видны только при IncludeDeleted; reverted +// остаётся видимым по умолчанию. Точечно к ним не фильтруем (см. tasks 2.1). func statesInGroup(g StateGroup) []State { switch g { case GroupReview: @@ -42,7 +43,7 @@ func statesInGroup(g StateGroup) []State { type ListFilter struct { Group StateGroup // группа состояний ("" / all — без сужения) Query string // поиск по id/названию/infohash/контексту (подстрока) - IncludeDeleted bool // показывать состояние deleted (только под all) + IncludeDeleted bool // показывать скрытые терминальные deleted+cancelled (только под all) Limit int // размер страницы Offset int // смещение страницы } @@ -60,9 +61,11 @@ func listWhere(f ListFilter) (string, []any) { } conds = append(conds, "state IN ("+strings.Join(ph, ",")+")") } else if !f.IncludeDeleted { - // Группа all: скрываем удалённые по умолчанию (шум в ленте). - conds = append(conds, "state <> ?") - args = append(args, string(StateDeleted)) + // Группа all: скрываем терминальные deleted и cancelled по умолчанию (шум + // в ленте; cancelled плодит универсальный стоп-кран «Закрыть»). reverted + // оставляем видимым. Оба показываются тумблером IncludeDeleted. + conds = append(conds, "state NOT IN (?, ?)") + args = append(args, string(StateDeleted), string(StateCancelled)) } if q := strings.TrimSpace(f.Query); q != "" { @@ -90,8 +93,9 @@ func listWhere(f ListFilter) (string, []any) { // строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени // добавления в источник (source_added_at) с фолбеком на created_at и tie-break // по id: без вторичного ключа при равных метках порядок неустойчив, и строки -// задваивались бы/пропадали между страницами. RecTitle подтягивается LEFT JOIN -// текущей попытки распознавания — для фолбека заголовка карточки. +// задваивались бы/пропадали между страницами. RecTitle и RecMediaType +// подтягиваются LEFT JOIN текущей попытки распознавания — для фолбека заголовка +// и значка типа карточки. func (s *Store) ListDownloadsPage(ctx context.Context, f ListFilter) ([]Download, int, error) { where, args := listWhere(f) @@ -100,7 +104,7 @@ func (s *Store) ListDownloadsPage(ctx context.Context, f ListFilter) ([]Download return nil, 0, fmt.Errorf("list downloads page count: %w", err) } - q := `SELECT download.*, r.title AS rec_title + q := `SELECT download.*, r.title AS rec_title, r.media_type AS rec_media_type 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 diff --git a/internal/store/list_test.go b/internal/store/list_test.go index cf0ec78..77fc594 100644 --- a/internal/store/list_test.go +++ b/internal/store/list_test.go @@ -45,6 +45,7 @@ func TestListDownloadsPageFilterAndDeleted(t *testing.T) { active := mkDownload(t, st, 2, StateDownloading, "Fargo") done := mkDownload(t, st, 3, StateDone, "Оно") deleted := mkDownload(t, st, 4, StateDeleted, "Старьё") + cancelled := mkDownload(t, st, 5, StateCancelled, "Отменённое") // Группа review — только review/deferred. page, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupReview, Limit: 50}) @@ -55,29 +56,44 @@ func TestListDownloadsPageFilterAndDeleted(t *testing.T) { t.Fatalf("review group = %v (total %d), want [%s]", ids(page), total, review) } - // all: deleted скрыт по умолчанию. + // all: deleted и cancelled скрыты по умолчанию (скрытые терминальные). 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)) + t.Fatalf("all (без скрытых терминальных) total = %d, want 3; ids=%v", total, ids(page)) } for _, d := range page { if d.ID == deleted { t.Fatalf("deleted не должен быть виден по умолчанию") } + if d.ID == cancelled { + t.Fatalf("cancelled не должен быть виден по умолчанию") + } } _ = active _ = done - // all + IncludeDeleted: показываем всё. - _, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, IncludeDeleted: true, Limit: 50}) + // all + IncludeDeleted: показываем всё, включая deleted и cancelled. + page, 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) + if total != 5 { + t.Fatalf("all+скрытые терминальные total = %d, want 5", total) + } + var seenDeleted, seenCancelled bool + for _, d := range page { + switch d.ID { + case deleted: + seenDeleted = true + case cancelled: + seenCancelled = true + } + } + if !seenDeleted || !seenCancelled { + t.Fatalf("под IncludeDeleted должны быть видны deleted и cancelled: deleted=%v cancelled=%v", seenDeleted, seenCancelled) } } @@ -278,6 +294,32 @@ func TestListDownloadsPageRecTitleFallback(t *testing.T) { } } +func TestListDownloadsPageRecMediaType(t *testing.T) { + st := newTestStore(t) + ctx := context.Background() + + // Тип текущей попытки распознавания подтягивается join'ом для значка списка. + id := mkDownload(t, st, 1, StateReview, "Fargo") + if _, err := st.CreateRecognition(ctx, &Recognition{ + DownloadID: id, + MediaType: NullString("series"), + Title: NullString("Fargo"), + }, 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].RecMediaType.Valid || page[0].RecMediaType.String != "series" { + t.Fatalf("RecMediaType = %q (valid=%v), want series", page[0].RecMediaType.String, page[0].RecMediaType.Valid) + } +} + func TestCreateDownloadPersistsDisplayName(t *testing.T) { st := newTestStore(t) ctx := context.Background() diff --git a/web/static/css/jellybit.css b/web/static/css/jellybit.css index 25adfb7..8f468c6 100644 --- a/web/static/css/jellybit.css +++ b/web/static/css/jellybit.css @@ -191,7 +191,8 @@ a:hover{text-decoration:underline} padding:var(--sp-4);box-shadow:var(--shadow-sm);margin-bottom:var(--sp-5)} .add-row{display:flex;gap:var(--sp-2)} .add-row .input{flex:1} -.btn-file{color:var(--text-dim);font-weight:500} +.btn-file{color:var(--text-dim);font-weight:500;max-width:14rem;overflow:hidden} +.btn-file [data-file-label]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0} .btn-file.has-file{color:var(--accent);border-color:var(--accent)} .add-hint{margin:9px 2px 0;font-size:var(--fs-xs);color:var(--text-faint)} diff --git a/web/templates/index.html b/web/templates/index.html index 637e663..91cab01 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -27,7 +27,7 @@ @@ -50,7 +50,7 @@
- {{if .ShowAll}}☑{{else}}☐{{end}} Показать всё, включая удалённые + {{if .ShowAll}}☑{{else}}☐{{end}} Показать всё, включая отменённые и удалённые
{{end}} diff --git a/web/templates/partials/card.html b/web/templates/partials/card.html index a562bcf..dba7f7a 100644 --- a/web/templates/partials/card.html +++ b/web/templates/partials/card.html @@ -2,7 +2,7 @@
- +
{{if eq .MediaType "series"}}📺 {{else if eq .MediaType "movie"}}🎬 {{end}}{{.Title}}
ID: {{.ID}}