Список: скрытие cancelled, значок типа, ellipsis имени .torrent
Три мелкие правки веб-UI списка загрузок: - Скрываем cancelled в общем списке наравне с deleted: под группой all без IncludeDeleted теперь `state NOT IN (deleted, cancelled)`. reverted остаётся видимым. Тумблер и комментарии-инварианты приведены в соответствие («включая отменённые и удалённые»). - Значок типа (🎬 фильм / 📺 сериал) в строке списка: media_type текущей попытки распознавания протянут через LEFT JOIN в ListDownloadsPage (Download.RecMediaType, симметрично RecTitle) и downloadView.MediaType; значок рендерится в card.html, при нераспознанном типе значка нет. - Ellipsis для длинного имени .torrent-файла: у .btn-file max-width + overflow/ellipsis на лейбле, чтобы длинное имя не распирало .add-row; показываем базовое имя без .torrent, полное — в title (JS-энхансмент). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -200,6 +200,7 @@ type pageLink struct {
|
|||||||
type downloadView struct {
|
type downloadView struct {
|
||||||
ID string
|
ID string
|
||||||
Title string // отображаемый заголовок карточки
|
Title string // отображаемый заголовок карточки
|
||||||
|
MediaType string // тип контента для значка строки списка (movie/series; пусто — не распознан)
|
||||||
State string
|
State string
|
||||||
Error string
|
Error string
|
||||||
Terminal bool
|
Terminal bool
|
||||||
@@ -652,6 +653,7 @@ func (s *server) toView(d store.Download, now time.Time) downloadView {
|
|||||||
v := downloadView{
|
v := downloadView{
|
||||||
ID: d.ID,
|
ID: d.ID,
|
||||||
Title: downloadTitle(d),
|
Title: downloadTitle(d),
|
||||||
|
MediaType: d.RecMediaType.String, // пусто, если распознавания/типа ещё нет
|
||||||
State: state,
|
State: state,
|
||||||
Error: d.ErrorMsg.String,
|
Error: d.ErrorMsg.String,
|
||||||
Terminal: d.State.IsTerminal(),
|
Terminal: d.State.IsTerminal(),
|
||||||
|
|||||||
@@ -204,6 +204,11 @@ type Download struct {
|
|||||||
// Заполняется только листингом ListDownloadsPage для фолбека заголовка; в
|
// Заполняется только листингом ListDownloadsPage для фолбека заголовка; в
|
||||||
// прочих выборках остаётся пустым.
|
// прочих выборках остаётся пустым.
|
||||||
RecTitle sql.NullString `db:"rec_title"`
|
RecTitle sql.NullString `db:"rec_title"`
|
||||||
|
|
||||||
|
// RecMediaType — тип текущей попытки распознавания (movie/series; LEFT JOIN
|
||||||
|
// recognition). Как и RecTitle, заполняется только листингом
|
||||||
|
// ListDownloadsPage — для значка типа в строке списка; в прочих выборках пуст.
|
||||||
|
RecMediaType sql.NullString `db:"rec_media_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// HashList — все хеши загрузки списком (для сопоставления с qBittorrent).
|
// HashList — все хеши загрузки списком (для сопоставления с qBittorrent).
|
||||||
|
|||||||
+14
-10
@@ -11,7 +11,7 @@ import (
|
|||||||
type StateGroup string
|
type StateGroup string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
GroupAll StateGroup = "all" // все, кроме deleted (если не IncludeDeleted)
|
GroupAll StateGroup = "all" // все, кроме скрытых терминальных (deleted/cancelled), если не IncludeDeleted
|
||||||
GroupReview StateGroup = "review" // ждут человека
|
GroupReview StateGroup = "review" // ждут человека
|
||||||
GroupActive StateGroup = "active" // в работе
|
GroupActive StateGroup = "active" // в работе
|
||||||
GroupDone StateGroup = "done" // готово
|
GroupDone StateGroup = "done" // готово
|
||||||
@@ -21,8 +21,9 @@ const (
|
|||||||
// statesInGroup возвращает состояния группы фильтра. nil — группа не сужает
|
// statesInGroup возвращает состояния группы фильтра. nil — группа не сужает
|
||||||
// выборку по состоянию (all/неизвестная): состояния перечисляются здесь и
|
// выборку по состоянию (all/неизвестная): состояния перечисляются здесь и
|
||||||
// нигде больше, чтобы UI и запрос не разъезжались. Терминальные cancelled/
|
// нигде больше, чтобы UI и запрос не разъезжались. Терминальные cancelled/
|
||||||
// reverted/deleted своей группы (чипа) не имеют — под all они видны (deleted —
|
// reverted/deleted своей группы (чипа) не имеют. Под all скрытые терминальные
|
||||||
// только при IncludeDeleted), точечно к ним не фильтруем (см. tasks 2.1).
|
// (deleted и cancelled — шум в ленте) видны только при IncludeDeleted; reverted
|
||||||
|
// остаётся видимым по умолчанию. Точечно к ним не фильтруем (см. tasks 2.1).
|
||||||
func statesInGroup(g StateGroup) []State {
|
func statesInGroup(g StateGroup) []State {
|
||||||
switch g {
|
switch g {
|
||||||
case GroupReview:
|
case GroupReview:
|
||||||
@@ -42,7 +43,7 @@ func statesInGroup(g StateGroup) []State {
|
|||||||
type ListFilter struct {
|
type ListFilter struct {
|
||||||
Group StateGroup // группа состояний ("" / all — без сужения)
|
Group StateGroup // группа состояний ("" / all — без сужения)
|
||||||
Query string // поиск по id/названию/infohash/контексту (подстрока)
|
Query string // поиск по id/названию/infohash/контексту (подстрока)
|
||||||
IncludeDeleted bool // показывать состояние deleted (только под all)
|
IncludeDeleted bool // показывать скрытые терминальные deleted+cancelled (только под all)
|
||||||
Limit int // размер страницы
|
Limit int // размер страницы
|
||||||
Offset int // смещение страницы
|
Offset int // смещение страницы
|
||||||
}
|
}
|
||||||
@@ -60,9 +61,11 @@ func listWhere(f ListFilter) (string, []any) {
|
|||||||
}
|
}
|
||||||
conds = append(conds, "state IN ("+strings.Join(ph, ",")+")")
|
conds = append(conds, "state IN ("+strings.Join(ph, ",")+")")
|
||||||
} else if !f.IncludeDeleted {
|
} else if !f.IncludeDeleted {
|
||||||
// Группа all: скрываем удалённые по умолчанию (шум в ленте).
|
// Группа all: скрываем терминальные deleted и cancelled по умолчанию (шум
|
||||||
conds = append(conds, "state <> ?")
|
// в ленте; cancelled плодит универсальный стоп-кран «Закрыть»). reverted
|
||||||
args = append(args, string(StateDeleted))
|
// оставляем видимым. Оба показываются тумблером IncludeDeleted.
|
||||||
|
conds = append(conds, "state NOT IN (?, ?)")
|
||||||
|
args = append(args, string(StateDeleted), string(StateCancelled))
|
||||||
}
|
}
|
||||||
|
|
||||||
if q := strings.TrimSpace(f.Query); q != "" {
|
if q := strings.TrimSpace(f.Query); q != "" {
|
||||||
@@ -90,8 +93,9 @@ func listWhere(f ListFilter) (string, []any) {
|
|||||||
// строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени
|
// строк, удовлетворяющих фильтру (для пагинации). Сортировка — по времени
|
||||||
// добавления в источник (source_added_at) с фолбеком на created_at и tie-break
|
// добавления в источник (source_added_at) с фолбеком на created_at и tie-break
|
||||||
// по id: без вторичного ключа при равных метках порядок неустойчив, и строки
|
// по id: без вторичного ключа при равных метках порядок неустойчив, и строки
|
||||||
// задваивались бы/пропадали между страницами. RecTitle подтягивается LEFT JOIN
|
// задваивались бы/пропадали между страницами. RecTitle и RecMediaType
|
||||||
// текущей попытки распознавания — для фолбека заголовка карточки.
|
// подтягиваются LEFT JOIN текущей попытки распознавания — для фолбека заголовка
|
||||||
|
// и значка типа карточки.
|
||||||
func (s *Store) ListDownloadsPage(ctx context.Context, f ListFilter) ([]Download, int, error) {
|
func (s *Store) ListDownloadsPage(ctx context.Context, f ListFilter) ([]Download, int, error) {
|
||||||
where, args := listWhere(f)
|
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)
|
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
|
FROM download
|
||||||
LEFT JOIN recognition r ON r.download_id = download.id AND r.is_current = 1` + where +
|
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
|
` ORDER BY COALESCE(download.source_added_at, download.created_at) DESC, download.id DESC
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func TestListDownloadsPageFilterAndDeleted(t *testing.T) {
|
|||||||
active := mkDownload(t, st, 2, StateDownloading, "Fargo")
|
active := mkDownload(t, st, 2, StateDownloading, "Fargo")
|
||||||
done := mkDownload(t, st, 3, StateDone, "Оно")
|
done := mkDownload(t, st, 3, StateDone, "Оно")
|
||||||
deleted := mkDownload(t, st, 4, StateDeleted, "Старьё")
|
deleted := mkDownload(t, st, 4, StateDeleted, "Старьё")
|
||||||
|
cancelled := mkDownload(t, st, 5, StateCancelled, "Отменённое")
|
||||||
|
|
||||||
// Группа review — только review/deferred.
|
// Группа review — только review/deferred.
|
||||||
page, total, err := st.ListDownloadsPage(ctx, ListFilter{Group: GroupReview, Limit: 50})
|
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)
|
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})
|
page, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, Limit: 50})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if total != 3 {
|
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 {
|
for _, d := range page {
|
||||||
if d.ID == deleted {
|
if d.ID == deleted {
|
||||||
t.Fatalf("deleted не должен быть виден по умолчанию")
|
t.Fatalf("deleted не должен быть виден по умолчанию")
|
||||||
}
|
}
|
||||||
|
if d.ID == cancelled {
|
||||||
|
t.Fatalf("cancelled не должен быть виден по умолчанию")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ = active
|
_ = active
|
||||||
_ = done
|
_ = done
|
||||||
|
|
||||||
// all + IncludeDeleted: показываем всё.
|
// all + IncludeDeleted: показываем всё, включая deleted и cancelled.
|
||||||
_, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, IncludeDeleted: true, Limit: 50})
|
page, total, err = st.ListDownloadsPage(ctx, ListFilter{Group: GroupAll, IncludeDeleted: true, Limit: 50})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if total != 4 {
|
if total != 5 {
|
||||||
t.Fatalf("all+deleted total = %d, want 4", total)
|
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) {
|
func TestCreateDownloadPersistsDisplayName(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -191,7 +191,8 @@ a:hover{text-decoration:underline}
|
|||||||
padding:var(--sp-4);box-shadow:var(--shadow-sm);margin-bottom:var(--sp-5)}
|
padding:var(--sp-4);box-shadow:var(--shadow-sm);margin-bottom:var(--sp-5)}
|
||||||
.add-row{display:flex;gap:var(--sp-2)}
|
.add-row{display:flex;gap:var(--sp-2)}
|
||||||
.add-row .input{flex:1}
|
.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)}
|
.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)}
|
.add-hint{margin:9px 2px 0;font-size:var(--fs-xs);color:var(--text-faint)}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<label class="btn btn-file" title="Выбрать .torrent-файл">
|
<label class="btn btn-file" title="Выбрать .torrent-файл">
|
||||||
<span data-file-label>.torrent</span>
|
<span data-file-label>.torrent</span>
|
||||||
<input type="file" name="torrent" accept=".torrent,application/x-bittorrent" hidden
|
<input type="file" name="torrent" accept=".torrent,application/x-bittorrent" hidden
|
||||||
onchange="var l=this.closest('.btn-file'),s=l.querySelector('[data-file-label]');s.textContent=this.files.length?this.files[0].name:'.torrent';l.classList.toggle('has-file',!!this.files.length)">
|
onchange="var l=this.closest('.btn-file'),s=l.querySelector('[data-file-label]'),f=this.files[0];s.textContent=f?f.name.replace(/\.torrent$/i,''):'.torrent';l.title=f?f.name:'Выбрать .torrent-файл';l.classList.toggle('has-file',!!f)">
|
||||||
</label>
|
</label>
|
||||||
<button class="btn btn-primary btn-lg" type="submit">Добавить</button>
|
<button class="btn btn-primary btn-lg" type="submit">Добавить</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toggle-line">
|
<div class="toggle-line">
|
||||||
<a href="{{.ShowURL}}">{{if .ShowAll}}☑{{else}}☐{{end}} Показать всё, включая удалённые</a>
|
<a href="{{.ShowURL}}">{{if .ShowAll}}☑{{else}}☐{{end}} Показать всё, включая отменённые и удалённые</a>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<article class="card" id="card-{{.ID}}"{{if .SelfPoll}} hx-get="/fragments/downloads/{{.ID}}/card" hx-trigger="every 3s" hx-swap="outerHTML"{{end}}>
|
<article class="card" id="card-{{.ID}}"{{if .SelfPoll}} hx-get="/fragments/downloads/{{.ID}}/card" hx-trigger="every 3s" hx-swap="outerHTML"{{end}}>
|
||||||
<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">{{if eq .MediaType "series"}}<span class="type-ico" title="сериал">📺</span> {{else if eq .MediaType "movie"}}<span class="type-ico" title="фильм">🎬</span> {{end}}<a href="/download/{{.ID}}" style="color:inherit">{{.Title}}</a></div>
|
||||||
<div class="hash-row">
|
<div class="hash-row">
|
||||||
<span class="hash-label">ID:</span>
|
<span class="hash-label">ID:</span>
|
||||||
<span class="hash">{{.ID}}</span>
|
<span class="hash">{{.ID}}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user