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 ( import (
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"html/template" "html/template"
"maps"
"net/http" "net/http"
"net/url" "net/url"
"regexp" "regexp"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -342,6 +345,11 @@ func (q *queryParametersField) UnmarshalYAML(node *yaml.Node) error {
return nil return nil
} }
type sortKey struct {
name string
dir int // 1 for asc, -1 for desc
}
func (q *queryParametersField) toQueryString() string { func (q *queryParametersField) toQueryString() string {
query := url.Values{} query := url.Values{}
@ -353,3 +361,86 @@ func (q *queryParametersField) toQueryString() string {
return query.Encode() 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 }} {{- else }}
<li title="{{ .StateDescription }}">{{ .State }}</li> <li title="{{ .StateDescription }}">{{ .State }}</li>
{{- end }} {{- end }}
{{- if .SpeedFormatted }} {{- if .DownSpeedFormatted }}
<li>{{ .SpeedFormatted }}/s</li> <li>{{ .DownSpeedFormatted }}/s</li>
{{- end }} {{- end }}
{{- if eq .State "Downloading" }} {{- if eq .State "Downloading" }}
<li>{{ .ETAFormatted }} ETA</li> <li>{{ .ETAFormatted }} ETA</li>

@ -245,3 +245,22 @@ func hslToHex(h, s, l float64) string {
return fmt.Sprintf("#%02x%02x%02x", ir, ig, ib) 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 { type torrentsWidget struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
URL string `yaml:"url"` URL string `yaml:"url"`
AllowInsecure bool `yaml:"allow-insecure"` AllowInsecure bool `yaml:"allow-insecure"`
Username string `yaml:"username"` Username string `yaml:"username"`
@ -27,10 +28,11 @@ type torrentsWidget struct {
CollapseAfter int `yaml:"collapse-after"` CollapseAfter int `yaml:"collapse-after"`
Client string `yaml:"client"` Client string `yaml:"client"`
SortBy sortableFields[torrent] `yaml:"sort-by"`
Torrents []torrent `yaml:"-"` Torrents []torrent `yaml:"-"`
// QBittorrent client sessionID string
sessionID string // session ID for authentication
} }
func (widget *torrentsWidget) initialize() error { func (widget *torrentsWidget) initialize() error {
@ -65,6 +67,30 @@ func (widget *torrentsWidget) initialize() error {
return fmt.Errorf("unsupported client: %s", widget.Client) 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 return nil
} }
@ -83,17 +109,7 @@ func (widget *torrentsWidget) update(ctx context.Context) {
return return
} }
// Sort by Downloaded status first, then by Name widget.SortBy.Apply(torrents)
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)
})
if len(torrents) > widget.Limit { if len(torrents) > widget.Limit {
torrents = torrents[:widget.Limit] torrents = torrents[:widget.Limit]
@ -110,7 +126,7 @@ const (
torrentStatusDownloading = "Downloading" torrentStatusDownloading = "Downloading"
torrentStatusDownloaded = "Downloaded" torrentStatusDownloaded = "Downloaded"
torrentStatusSeeding = "Seeding" torrentStatusSeeding = "Seeding"
torrentStatusStopped = "Stopped" torrentStatusPaused = "Paused"
torrentStatusStalled = "Stalled" torrentStatusStalled = "Stalled"
torrentStatusError = "Error" torrentStatusError = "Error"
torrentStatusOther = "Other" torrentStatusOther = "Other"
@ -131,11 +147,11 @@ var qbittorrentStates = map[string][2]string{
"forcedUP": {torrentStatusSeeding, "Torrent is forced to upload, ignoring queue limit"}, "forcedUP": {torrentStatusSeeding, "Torrent is forced to upload, ignoring queue limit"},
// Stopped/Paused states // Stopped/Paused states
"stoppedDL": {torrentStatusStopped, "Torrent is stopped"}, "stoppedDL": {torrentStatusPaused, "Torrent is stopped"},
"pausedDL": {torrentStatusStopped, "Torrent is paused and has not finished downloading"}, "pausedDL": {torrentStatusPaused, "Torrent is paused and has not finished downloading"},
"pausedUP": {torrentStatusStopped, "Torrent is paused and has finished downloading"}, "pausedUP": {torrentStatusPaused, "Torrent is paused and has finished downloading"},
"queuedDL": {torrentStatusStopped, "Queuing is enabled and torrent is queued for download"}, "queuedDL": {torrentStatusPaused, "Queuing is enabled and torrent is queued for download"},
"queuedUP": {torrentStatusStopped, "Queuing is enabled and torrent is queued for upload"}, "queuedUP": {torrentStatusPaused, "Queuing is enabled and torrent is queued for upload"},
// Stalled states // Stalled states
"stalledDL": {torrentStatusStalled, "Torrent is being downloaded, but no connections were made"}, "stalledDL": {torrentStatusStalled, "Torrent is being downloaded, but no connections were made"},
@ -158,7 +174,9 @@ type torrent struct {
Progress float64 Progress float64
State string State string
StateDescription string StateDescription string
SpeedFormatted string UpSpeed uint64
DownSpeed uint64
DownSpeedFormatted string
ETAFormatted string ETAFormatted string
} }
@ -234,7 +252,8 @@ func (widget *torrentsWidget) _fetchQbtTorrents() ([]torrent, bool, error) {
Name string `json:"name"` Name string `json:"name"`
Progress float64 `json:"progress"` Progress float64 `json:"progress"`
State string `json:"state"` State string `json:"state"`
Speed uint64 `json:"dlspeed"` DownSpeed uint64 `json:"dlspeed"`
UpSpeed uint64 `json:"upspeed"`
ETA uint64 `json:"eta"` // in seconds ETA uint64 `json:"eta"` // in seconds
} }
@ -245,7 +264,6 @@ func (widget *torrentsWidget) _fetchQbtTorrents() ([]torrent, bool, error) {
torrents := make([]torrent, len(rawTorrents)) torrents := make([]torrent, len(rawTorrents))
for i, raw := range rawTorrents { for i, raw := range rawTorrents {
state := raw.State state := raw.State
stateDescription := "Unknown state" stateDescription := "Unknown state"
if mappedState, exists := qbittorrentStates[raw.State]; exists { if mappedState, exists := qbittorrentStates[raw.State]; exists {
@ -261,11 +279,13 @@ func (widget *torrentsWidget) _fetchQbtTorrents() ([]torrent, bool, error) {
State: state, State: state,
StateDescription: stateDescription, StateDescription: stateDescription,
ETAFormatted: widget.formatETA(raw.ETA), ETAFormatted: widget.formatETA(raw.ETA),
DownSpeed: raw.DownSpeed,
UpSpeed: raw.UpSpeed,
} }
if raw.Speed > 0 { if raw.DownSpeed > 0 {
speedValue, speedUnit := formatBytes(raw.Speed) value, unit := formatBytes(raw.DownSpeed)
torrents[i].SpeedFormatted = fmt.Sprintf("%s %s", speedValue, speedUnit) torrents[i].DownSpeedFormatted = fmt.Sprintf("%s %s", value, unit)
} }
} }
@ -303,7 +323,6 @@ func (widget *torrentsWidget) fetchQbtSessionID() error {
cookies := resp.Cookies() cookies := resp.Cookies()
if len(cookies) == 0 { if len(cookies) == 0 {
fmt.Println(string(body))
return errors.New("no session cookie received, maybe the username or password is incorrect?") return errors.New("no session cookie received, maybe the username or password is incorrect?")
} }