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

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