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

@ -18,7 +18,8 @@ import (
var torrentsWidgetTemplate = mustParseTemplate("torrents.html", "widget-base.html") var torrentsWidgetTemplate = mustParseTemplate("torrents.html", "widget-base.html")
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"},
@ -152,14 +168,16 @@ var qbittorrentStates = map[string][2]string{
} }
type torrent struct { type torrent struct {
Name string Name string
ProgressFormatted string ProgressFormatted string
Downloaded bool Downloaded bool
Progress float64 Progress float64
State string State string
StateDescription string StateDescription string
SpeedFormatted string UpSpeed uint64
ETAFormatted string DownSpeed uint64
DownSpeedFormatted string
ETAFormatted string
} }
func (widget *torrentsWidget) formatETA(seconds uint64) string { func (widget *torrentsWidget) formatETA(seconds uint64) string {
@ -231,11 +249,12 @@ func (widget *torrentsWidget) _fetchQbtTorrents() ([]torrent, bool, error) {
} }
type qbTorrent struct { type qbTorrent struct {
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"`
ETA uint64 `json:"eta"` // in seconds UpSpeed uint64 `json:"upspeed"`
ETA uint64 `json:"eta"` // in seconds
} }
var rawTorrents []qbTorrent var rawTorrents []qbTorrent
@ -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?")
} }