From a511e39bc71a5cb3a335e137e0a79fbf45711a95 Mon Sep 17 00:00:00 2001
From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com>
Date: Tue, 26 Aug 2025 06:31:09 +0100
Subject: [PATCH] Allow sorting torrents by multiple fields
---
internal/glance/config-fields.go | 91 +++++++++++++++++++++++
internal/glance/templates/torrents.html | 4 +-
internal/glance/utils.go | 19 +++++
internal/glance/widget-torrents.go | 95 +++++++++++++++----------
4 files changed, 169 insertions(+), 40 deletions(-)
diff --git a/internal/glance/config-fields.go b/internal/glance/config-fields.go
index c1fa73b..9e523c1 100644
--- a/internal/glance/config-fields.go
+++ b/internal/glance/config-fields.go
@@ -2,11 +2,14 @@ package glance
import (
"crypto/tls"
+ "errors"
"fmt"
"html/template"
+ "maps"
"net/http"
"net/url"
"regexp"
+ "slices"
"strconv"
"strings"
"time"
@@ -342,6 +345,11 @@ func (q *queryParametersField) UnmarshalYAML(node *yaml.Node) error {
return nil
}
+type sortKey struct {
+ name string
+ dir int // 1 for asc, -1 for desc
+}
+
func (q *queryParametersField) toQueryString() string {
query := url.Values{}
@@ -353,3 +361,86 @@ func (q *queryParametersField) toQueryString() string {
return query.Encode()
}
+
+type sortableFields[T any] struct {
+ keys []sortKey
+ fields map[string]func(a, b T) int
+}
+
+func (s *sortableFields[T]) UnmarshalYAML(node *yaml.Node) error {
+ var raw string
+ if err := node.Decode(&raw); err != nil {
+ return errors.New("sort-by must be a string")
+ }
+
+ return s.parse(raw)
+}
+
+func (s *sortableFields[T]) parse(raw string) error {
+ split := strings.Split(raw, ",")
+
+ for i := range split {
+ key := strings.TrimSpace(split[i])
+ direction := "asc"
+ name, dir, ok := strings.Cut(key, ":")
+ if ok {
+ key = strings.TrimSpace(name)
+ direction = strings.TrimSpace(dir)
+ if direction != "asc" && direction != "desc" {
+ return fmt.Errorf("unsupported sort direction `%s` in sort-by option `%s`, must be `asc` or `desc`", direction, key)
+ }
+ }
+
+ if key == "" {
+ continue
+ }
+
+ s.keys = append(s.keys, sortKey{
+ name: key,
+ dir: ternary(direction == "asc", 1, -1),
+ })
+ }
+
+ return nil
+}
+
+func (s *sortableFields[T]) Default(sort string) error {
+ if len(s.keys) == 0 {
+ return s.parse(sort)
+ }
+
+ return nil
+}
+
+func (s *sortableFields[T]) Fields(fields map[string]func(a, b T) int) error {
+ for i := range s.keys {
+ option := s.keys[i].name
+ if _, ok := fields[option]; !ok {
+ keys := slices.Collect(maps.Keys(fields))
+ slices.Sort(keys)
+ formatted := strings.Join(keys, ", ")
+ return fmt.Errorf("unsupported sort-by option `%s`, must be one or more of [%s] separated by comma", option, formatted)
+ }
+ }
+
+ s.fields = fields
+
+ return nil
+}
+
+func (s *sortableFields[T]) Apply(data []T) {
+ slices.SortStableFunc(data, func(a, b T) int {
+ for _, key := range s.keys {
+ field, ok := s.fields[key.name]
+ if !ok {
+ continue
+ }
+
+ if result := field(a, b) * key.dir; result != 0 {
+ return result
+ }
+ }
+
+ return 0
+ })
+}
diff --git a/internal/glance/templates/torrents.html b/internal/glance/templates/torrents.html
index eb51408..df27e80 100644
--- a/internal/glance/templates/torrents.html
+++ b/internal/glance/templates/torrents.html
@@ -12,8 +12,8 @@
{{- else }}
{{ .State }}
{{- end }}
- {{- if .SpeedFormatted }}
- {{ .SpeedFormatted }}/s
+ {{- if .DownSpeedFormatted }}
+ {{ .DownSpeedFormatted }}/s
{{- end }}
{{- if eq .State "Downloading" }}
{{ .ETAFormatted }} ETA
diff --git a/internal/glance/utils.go b/internal/glance/utils.go
index 21cd69b..c1276c3 100644
--- a/internal/glance/utils.go
+++ b/internal/glance/utils.go
@@ -245,3 +245,22 @@ func hslToHex(h, s, l float64) string {
return fmt.Sprintf("#%02x%02x%02x", ir, ig, ib)
}
+
+func numCompare[T int | uint64 | float64](a, b T) int {
+ if a < b {
+ return -1
+ } else if a > b {
+ return 1
+ }
+ return 0
+}
+
+func boolCompare(a, b bool) int {
+ if a == b {
+ return 0
+ } else if !a && b {
+ return -1
+ } else {
+ return 1
+ }
+}
diff --git a/internal/glance/widget-torrents.go b/internal/glance/widget-torrents.go
index 66fbe6d..33f981e 100644
--- a/internal/glance/widget-torrents.go
+++ b/internal/glance/widget-torrents.go
@@ -18,7 +18,8 @@ import (
var torrentsWidgetTemplate = mustParseTemplate("torrents.html", "widget-base.html")
type torrentsWidget struct {
- widgetBase `yaml:",inline"`
+ widgetBase `yaml:",inline"`
+
URL string `yaml:"url"`
AllowInsecure bool `yaml:"allow-insecure"`
Username string `yaml:"username"`
@@ -27,10 +28,11 @@ type torrentsWidget struct {
CollapseAfter int `yaml:"collapse-after"`
Client string `yaml:"client"`
+ SortBy sortableFields[torrent] `yaml:"sort-by"`
+
Torrents []torrent `yaml:"-"`
- // QBittorrent client
- sessionID string // session ID for authentication
+ sessionID string
}
func (widget *torrentsWidget) initialize() error {
@@ -65,6 +67,30 @@ func (widget *torrentsWidget) initialize() error {
return fmt.Errorf("unsupported client: %s", widget.Client)
}
+ if err := widget.SortBy.Default("downloaded, down-speed:desc, up-speed:desc"); err != nil {
+ return err
+ }
+
+ if err := widget.SortBy.Fields(map[string]func(a, b torrent) int{
+ "name": func(a, b torrent) int {
+ return strings.Compare(a.Name, b.Name)
+ },
+ "progress": func(a, b torrent) int {
+ return numCompare(a.Progress, b.Progress)
+ },
+ "downloaded": func(a, b torrent) int {
+ return boolCompare(a.Downloaded, b.Downloaded)
+ },
+ "down-speed": func(a, b torrent) int {
+ return numCompare(a.DownSpeed, b.DownSpeed)
+ },
+ "up-speed": func(a, b torrent) int {
+ return numCompare(a.UpSpeed, b.UpSpeed)
+ },
+ }); err != nil {
+ return err
+ }
+
return nil
}
@@ -83,17 +109,7 @@ func (widget *torrentsWidget) update(ctx context.Context) {
return
}
- // Sort by Downloaded status first, then by Name
- slices.SortStableFunc(torrents, func(a, b torrent) int {
- if a.Downloaded != b.Downloaded {
- if !a.Downloaded && b.Downloaded {
- return -1
- }
- return 1
- }
-
- return strings.Compare(a.Name, b.Name)
- })
+ widget.SortBy.Apply(torrents)
if len(torrents) > widget.Limit {
torrents = torrents[:widget.Limit]
@@ -110,7 +126,7 @@ const (
torrentStatusDownloading = "Downloading"
torrentStatusDownloaded = "Downloaded"
torrentStatusSeeding = "Seeding"
- torrentStatusStopped = "Stopped"
+ torrentStatusPaused = "Paused"
torrentStatusStalled = "Stalled"
torrentStatusError = "Error"
torrentStatusOther = "Other"
@@ -131,11 +147,11 @@ var qbittorrentStates = map[string][2]string{
"forcedUP": {torrentStatusSeeding, "Torrent is forced to upload, ignoring queue limit"},
// Stopped/Paused states
- "stoppedDL": {torrentStatusStopped, "Torrent is stopped"},
- "pausedDL": {torrentStatusStopped, "Torrent is paused and has not finished downloading"},
- "pausedUP": {torrentStatusStopped, "Torrent is paused and has finished downloading"},
- "queuedDL": {torrentStatusStopped, "Queuing is enabled and torrent is queued for download"},
- "queuedUP": {torrentStatusStopped, "Queuing is enabled and torrent is queued for upload"},
+ "stoppedDL": {torrentStatusPaused, "Torrent is stopped"},
+ "pausedDL": {torrentStatusPaused, "Torrent is paused and has not finished downloading"},
+ "pausedUP": {torrentStatusPaused, "Torrent is paused and has finished downloading"},
+ "queuedDL": {torrentStatusPaused, "Queuing is enabled and torrent is queued for download"},
+ "queuedUP": {torrentStatusPaused, "Queuing is enabled and torrent is queued for upload"},
// Stalled states
"stalledDL": {torrentStatusStalled, "Torrent is being downloaded, but no connections were made"},
@@ -152,14 +168,16 @@ var qbittorrentStates = map[string][2]string{
}
type torrent struct {
- Name string
- ProgressFormatted string
- Downloaded bool
- Progress float64
- State string
- StateDescription string
- SpeedFormatted string
- ETAFormatted string
+ Name string
+ ProgressFormatted string
+ Downloaded bool
+ Progress float64
+ State string
+ StateDescription string
+ UpSpeed uint64
+ DownSpeed uint64
+ DownSpeedFormatted string
+ ETAFormatted string
}
func (widget *torrentsWidget) formatETA(seconds uint64) string {
@@ -231,11 +249,12 @@ func (widget *torrentsWidget) _fetchQbtTorrents() ([]torrent, bool, error) {
}
type qbTorrent struct {
- Name string `json:"name"`
- Progress float64 `json:"progress"`
- State string `json:"state"`
- Speed uint64 `json:"dlspeed"`
- ETA uint64 `json:"eta"` // in seconds
+ Name string `json:"name"`
+ Progress float64 `json:"progress"`
+ State string `json:"state"`
+ DownSpeed uint64 `json:"dlspeed"`
+ UpSpeed uint64 `json:"upspeed"`
+ ETA uint64 `json:"eta"` // in seconds
}
var rawTorrents []qbTorrent
@@ -245,7 +264,6 @@ func (widget *torrentsWidget) _fetchQbtTorrents() ([]torrent, bool, error) {
torrents := make([]torrent, len(rawTorrents))
for i, raw := range rawTorrents {
-
state := raw.State
stateDescription := "Unknown state"
if mappedState, exists := qbittorrentStates[raw.State]; exists {
@@ -261,11 +279,13 @@ func (widget *torrentsWidget) _fetchQbtTorrents() ([]torrent, bool, error) {
State: state,
StateDescription: stateDescription,
ETAFormatted: widget.formatETA(raw.ETA),
+ DownSpeed: raw.DownSpeed,
+ UpSpeed: raw.UpSpeed,
}
- if raw.Speed > 0 {
- speedValue, speedUnit := formatBytes(raw.Speed)
- torrents[i].SpeedFormatted = fmt.Sprintf("%s %s", speedValue, speedUnit)
+ if raw.DownSpeed > 0 {
+ value, unit := formatBytes(raw.DownSpeed)
+ torrents[i].DownSpeedFormatted = fmt.Sprintf("%s %s", value, unit)
}
}
@@ -303,7 +323,6 @@ func (widget *torrentsWidget) fetchQbtSessionID() error {
cookies := resp.Cookies()
if len(cookies) == 0 {
- fmt.Println(string(body))
return errors.New("no session cookie received, maybe the username or password is incorrect?")
}