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