Skip to content

Commit a511e39

Browse files
committed
Allow sorting torrents by multiple fields
1 parent 83956fe commit a511e39

File tree

4 files changed

+169
-40
lines changed

4 files changed

+169
-40
lines changed

internal/glance/config-fields.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ package glance
22

33
import (
44
"crypto/tls"
5+
"errors"
56
"fmt"
67
"html/template"
8+
"maps"
79
"net/http"
810
"net/url"
911
"regexp"
12+
"slices"
1013
"strconv"
1114
"strings"
1215
"time"
@@ -342,6 +345,11 @@ func (q *queryParametersField) UnmarshalYAML(node *yaml.Node) error {
342345
return nil
343346
}
344347

348+
type sortKey struct {
349+
name string
350+
dir int // 1 for asc, -1 for desc
351+
}
352+
345353
func (q *queryParametersField) toQueryString() string {
346354
query := url.Values{}
347355

@@ -353,3 +361,86 @@ func (q *queryParametersField) toQueryString() string {
353361

354362
return query.Encode()
355363
}
364+
365+
type sortableFields[T any] struct {
366+
keys []sortKey
367+
fields map[string]func(a, b T) int
368+
}
369+
370+
func (s *sortableFields[T]) UnmarshalYAML(node *yaml.Node) error {
371+
var raw string
372+
if err := node.Decode(&raw); err != nil {
373+
return errors.New("sort-by must be a string")
374+
}
375+
376+
return s.parse(raw)
377+
}
378+
379+
func (s *sortableFields[T]) parse(raw string) error {
380+
split := strings.Split(raw, ",")
381+
382+
for i := range split {
383+
key := strings.TrimSpace(split[i])
384+
direction := "asc"
385+
name, dir, ok := strings.Cut(key, ":")
386+
if ok {
387+
key = strings.TrimSpace(name)
388+
direction = strings.TrimSpace(dir)
389+
if direction != "asc" && direction != "desc" {
390+
return fmt.Errorf("unsupported sort direction `%s` in sort-by option `%s`, must be `asc` or `desc`", direction, key)
391+
}
392+
}
393+
394+
if key == "" {
395+
continue
396+
}
397+
398+
s.keys = append(s.keys, sortKey{
399+
name: key,
400+
dir: ternary(direction == "asc", 1, -1),
401+
})
402+
}
403+
404+
return nil
405+
}
406+
407+
func (s *sortableFields[T]) Default(sort string) error {
408+
if len(s.keys) == 0 {
409+
return s.parse(sort)
410+
}
411+
412+
return nil
413+
}
414+
415+
func (s *sortableFields[T]) Fields(fields map[string]func(a, b T) int) error {
416+
for i := range s.keys {
417+
option := s.keys[i].name
418+
if _, ok := fields[option]; !ok {
419+
keys := slices.Collect(maps.Keys(fields))
420+
slices.Sort(keys)
421+
formatted := strings.Join(keys, ", ")
422+
return fmt.Errorf("unsupported sort-by option `%s`, must be one or more of [%s] separated by comma", option, formatted)
423+
}
424+
}
425+
426+
s.fields = fields
427+
428+
return nil
429+
}
430+
431+
func (s *sortableFields[T]) Apply(data []T) {
432+
slices.SortStableFunc(data, func(a, b T) int {
433+
for _, key := range s.keys {
434+
field, ok := s.fields[key.name]
435+
if !ok {
436+
continue
437+
}
438+
439+
if result := field(a, b) * key.dir; result != 0 {
440+
return result
441+
}
442+
}
443+
444+
return 0
445+
})
446+
}

internal/glance/templates/torrents.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
{{- else }}
1313
<li title="{{ .StateDescription }}">{{ .State }}</li>
1414
{{- end }}
15-
{{- if .SpeedFormatted }}
16-
<li>{{ .SpeedFormatted }}/s</li>
15+
{{- if .DownSpeedFormatted }}
16+
<li>{{ .DownSpeedFormatted }}/s</li>
1717
{{- end }}
1818
{{- if eq .State "Downloading" }}
1919
<li>{{ .ETAFormatted }} ETA</li>

internal/glance/utils.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,3 +245,22 @@ func hslToHex(h, s, l float64) string {
245245

246246
return fmt.Sprintf("#%02x%02x%02x", ir, ig, ib)
247247
}
248+
249+
func numCompare[T int | uint64 | float64](a, b T) int {
250+
if a < b {
251+
return -1
252+
} else if a > b {
253+
return 1
254+
}
255+
return 0
256+
}
257+
258+
func boolCompare(a, b bool) int {
259+
if a == b {
260+
return 0
261+
} else if !a && b {
262+
return -1
263+
} else {
264+
return 1
265+
}
266+
}

internal/glance/widget-torrents.go

Lines changed: 57 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import (
1818
var torrentsWidgetTemplate = mustParseTemplate("torrents.html", "widget-base.html")
1919

2020
type torrentsWidget struct {
21-
widgetBase `yaml:",inline"`
21+
widgetBase `yaml:",inline"`
22+
2223
URL string `yaml:"url"`
2324
AllowInsecure bool `yaml:"allow-insecure"`
2425
Username string `yaml:"username"`
@@ -27,10 +28,11 @@ type torrentsWidget struct {
2728
CollapseAfter int `yaml:"collapse-after"`
2829
Client string `yaml:"client"`
2930

31+
SortBy sortableFields[torrent] `yaml:"sort-by"`
32+
3033
Torrents []torrent `yaml:"-"`
3134

32-
// QBittorrent client
33-
sessionID string // session ID for authentication
35+
sessionID string
3436
}
3537

3638
func (widget *torrentsWidget) initialize() error {
@@ -65,6 +67,30 @@ func (widget *torrentsWidget) initialize() error {
6567
return fmt.Errorf("unsupported client: %s", widget.Client)
6668
}
6769

70+
if err := widget.SortBy.Default("downloaded, down-speed:desc, up-speed:desc"); err != nil {
71+
return err
72+
}
73+
74+
if err := widget.SortBy.Fields(map[string]func(a, b torrent) int{
75+
"name": func(a, b torrent) int {
76+
return strings.Compare(a.Name, b.Name)
77+
},
78+
"progress": func(a, b torrent) int {
79+
return numCompare(a.Progress, b.Progress)
80+
},
81+
"downloaded": func(a, b torrent) int {
82+
return boolCompare(a.Downloaded, b.Downloaded)
83+
},
84+
"down-speed": func(a, b torrent) int {
85+
return numCompare(a.DownSpeed, b.DownSpeed)
86+
},
87+
"up-speed": func(a, b torrent) int {
88+
return numCompare(a.UpSpeed, b.UpSpeed)
89+
},
90+
}); err != nil {
91+
return err
92+
}
93+
6894
return nil
6995
}
7096

@@ -83,17 +109,7 @@ func (widget *torrentsWidget) update(ctx context.Context) {
83109
return
84110
}
85111

86-
// Sort by Downloaded status first, then by Name
87-
slices.SortStableFunc(torrents, func(a, b torrent) int {
88-
if a.Downloaded != b.Downloaded {
89-
if !a.Downloaded && b.Downloaded {
90-
return -1
91-
}
92-
return 1
93-
}
94-
95-
return strings.Compare(a.Name, b.Name)
96-
})
112+
widget.SortBy.Apply(torrents)
97113

98114
if len(torrents) > widget.Limit {
99115
torrents = torrents[:widget.Limit]
@@ -110,7 +126,7 @@ const (
110126
torrentStatusDownloading = "Downloading"
111127
torrentStatusDownloaded = "Downloaded"
112128
torrentStatusSeeding = "Seeding"
113-
torrentStatusStopped = "Stopped"
129+
torrentStatusPaused = "Paused"
114130
torrentStatusStalled = "Stalled"
115131
torrentStatusError = "Error"
116132
torrentStatusOther = "Other"
@@ -131,11 +147,11 @@ var qbittorrentStates = map[string][2]string{
131147
"forcedUP": {torrentStatusSeeding, "Torrent is forced to upload, ignoring queue limit"},
132148

133149
// Stopped/Paused states
134-
"stoppedDL": {torrentStatusStopped, "Torrent is stopped"},
135-
"pausedDL": {torrentStatusStopped, "Torrent is paused and has not finished downloading"},
136-
"pausedUP": {torrentStatusStopped, "Torrent is paused and has finished downloading"},
137-
"queuedDL": {torrentStatusStopped, "Queuing is enabled and torrent is queued for download"},
138-
"queuedUP": {torrentStatusStopped, "Queuing is enabled and torrent is queued for upload"},
150+
"stoppedDL": {torrentStatusPaused, "Torrent is stopped"},
151+
"pausedDL": {torrentStatusPaused, "Torrent is paused and has not finished downloading"},
152+
"pausedUP": {torrentStatusPaused, "Torrent is paused and has finished downloading"},
153+
"queuedDL": {torrentStatusPaused, "Queuing is enabled and torrent is queued for download"},
154+
"queuedUP": {torrentStatusPaused, "Queuing is enabled and torrent is queued for upload"},
139155

140156
// Stalled states
141157
"stalledDL": {torrentStatusStalled, "Torrent is being downloaded, but no connections were made"},
@@ -152,14 +168,16 @@ var qbittorrentStates = map[string][2]string{
152168
}
153169

154170
type torrent struct {
155-
Name string
156-
ProgressFormatted string
157-
Downloaded bool
158-
Progress float64
159-
State string
160-
StateDescription string
161-
SpeedFormatted string
162-
ETAFormatted string
171+
Name string
172+
ProgressFormatted string
173+
Downloaded bool
174+
Progress float64
175+
State string
176+
StateDescription string
177+
UpSpeed uint64
178+
DownSpeed uint64
179+
DownSpeedFormatted string
180+
ETAFormatted string
163181
}
164182

165183
func (widget *torrentsWidget) formatETA(seconds uint64) string {
@@ -231,11 +249,12 @@ func (widget *torrentsWidget) _fetchQbtTorrents() ([]torrent, bool, error) {
231249
}
232250

233251
type qbTorrent struct {
234-
Name string `json:"name"`
235-
Progress float64 `json:"progress"`
236-
State string `json:"state"`
237-
Speed uint64 `json:"dlspeed"`
238-
ETA uint64 `json:"eta"` // in seconds
252+
Name string `json:"name"`
253+
Progress float64 `json:"progress"`
254+
State string `json:"state"`
255+
DownSpeed uint64 `json:"dlspeed"`
256+
UpSpeed uint64 `json:"upspeed"`
257+
ETA uint64 `json:"eta"` // in seconds
239258
}
240259

241260
var rawTorrents []qbTorrent
@@ -245,7 +264,6 @@ func (widget *torrentsWidget) _fetchQbtTorrents() ([]torrent, bool, error) {
245264

246265
torrents := make([]torrent, len(rawTorrents))
247266
for i, raw := range rawTorrents {
248-
249267
state := raw.State
250268
stateDescription := "Unknown state"
251269
if mappedState, exists := qbittorrentStates[raw.State]; exists {
@@ -261,11 +279,13 @@ func (widget *torrentsWidget) _fetchQbtTorrents() ([]torrent, bool, error) {
261279
State: state,
262280
StateDescription: stateDescription,
263281
ETAFormatted: widget.formatETA(raw.ETA),
282+
DownSpeed: raw.DownSpeed,
283+
UpSpeed: raw.UpSpeed,
264284
}
265285

266-
if raw.Speed > 0 {
267-
speedValue, speedUnit := formatBytes(raw.Speed)
268-
torrents[i].SpeedFormatted = fmt.Sprintf("%s %s", speedValue, speedUnit)
286+
if raw.DownSpeed > 0 {
287+
value, unit := formatBytes(raw.DownSpeed)
288+
torrents[i].DownSpeedFormatted = fmt.Sprintf("%s %s", value, unit)
269289
}
270290
}
271291

@@ -303,7 +323,6 @@ func (widget *torrentsWidget) fetchQbtSessionID() error {
303323

304324
cookies := resp.Cookies()
305325
if len(cookies) == 0 {
306-
fmt.Println(string(body))
307326
return errors.New("no session cookie received, maybe the username or password is incorrect?")
308327
}
309328

0 commit comments

Comments
 (0)