Allow sorting torrents by multiple fields

pull/554/merge
Svilen Markov 2025-08-26 06:31:09 +07:00
parent 83956fe41d
commit a511e39bc7
4 changed files with 169 additions and 40 deletions

@ -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
})
}

@ -12,8 +12,8 @@
{{- else }}
<li title="{{ .StateDescription }}">{{ .State }}</li>
{{- end }}
{{- if .SpeedFormatted }}
<li>{{ .SpeedFormatted }}/s</li>
{{- if .DownSpeedFormatted }}
<li>{{ .DownSpeedFormatted }}/s</li>
{{- end }}
{{- if eq .State "Downloading" }}
<li>{{ .ETAFormatted }} ETA</li>

@ -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
}
}

@ -19,6 +19,7 @@ var torrentsWidgetTemplate = mustParseTemplate("torrents.html", "widget-base.htm
type torrentsWidget struct {
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"},
@ -158,7 +174,9 @@ type torrent struct {
Progress float64
State string
StateDescription string
SpeedFormatted string
UpSpeed uint64
DownSpeed uint64
DownSpeedFormatted string
ETAFormatted string
}
@ -234,7 +252,8 @@ func (widget *torrentsWidget) _fetchQbtTorrents() ([]torrent, bool, error) {
Name string `json:"name"`
Progress float64 `json:"progress"`
State string `json:"state"`
Speed uint64 `json:"dlspeed"`
DownSpeed uint64 `json:"dlspeed"`
UpSpeed uint64 `json:"upspeed"`
ETA uint64 `json:"eta"` // in seconds
}
@ -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?")
}