Compare commits

...

9 Commits

Author SHA1 Message Date
Svilen Markov a511e39bc7 Allow sorting torrents by multiple fields 2025-08-26 06:31:09 +07:00
Svilen Markov 83956fe41d Update repository widget property 2025-08-26 02:37:56 +07:00
Svilen Markov 7824413760
Merge pull request #543 from ThiemeH/feature/qbittorrent-widget
Added qBittorrent
2025-08-26 02:30:18 +07:00
Svilen Markov 76e1283d0a
Merge branch 'dev' into feature/qbittorrent-widget 2025-08-26 02:29:20 +07:00
Svilen Markov 6c4a5e8337 Refactor into a unified torrents widget 2025-08-26 02:26:57 +07:00
ThiemeH 7a1fd8acf9 Fixed text truncate, req params and css 2025-03-31 21:46:19 +07:00
ThiemeH 38bdc69e67 Added completed text 2025-03-31 21:37:24 +07:00
ThiemeH e14569821a Fixed bytes formatting 2025-03-31 21:33:43 +07:00
ThiemeH b46cd6601c Added qBittorrent 2025-03-31 20:27:14 +07:00
10 changed files with 548 additions and 36 deletions

@ -907,7 +907,7 @@ https://www.youtube.com...&list={ID}&...
The maximum number of videos to show.
##### `sort-by`
Used to specify the order in which the videos should get returned. Possible values are `none`, `updated`, and `posted`.
Used to specify the order in which the videos should get returned. Possible values are `none`, `updated`, and `posted`.
Default value is `posted`.
##### `collapse-after`
@ -2504,7 +2504,7 @@ Example:
pull-requests-limit: 5
issues-limit: 3
commits-limit: 3
exclude-draft-prs: true
exclude-draft-pull-requests: true
```
Preview:
@ -2520,7 +2520,7 @@ Preview:
| pull-requests-limit | integer | no | 3 |
| issues-limit | integer | no | 3 |
| commits-limit | integer | no | -1 |
| exclude-draft-prs | boolean | no | false |
| exclude-draft-pull-requests | boolean | no | false |
##### `repository`
The owner and repository name that will have their information displayed.
@ -2537,7 +2537,7 @@ The maximum number of latest open issues to show. Set to `-1` to not show any.
##### `commits-limit`
The maximum number of lastest commits to show from the default branch. Set to `-1` to not show any.
##### `exclude-draft-prs`
##### `exclude-draft-pull-requests`
Wheter to exclude draft pull requests from the list. Set to `false` by default to include them.
### Bookmarks

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

@ -38,6 +38,10 @@
top: 0.1rem;
}
.horizontal-text-gap-5 > *:not(:last-child)::after {
margin: 0 0.5rem;
}
.summary {
width: 100%;
cursor: pointer;
@ -424,6 +428,11 @@ details[open] .summary::after {
height: 3rem;
}
.progress-bar-mini {
height: .9rem;
border-radius: .3rem;
}
.popover-active > .progress-bar {
transition: border-color .3s;
border-color: var(--color-text-subdue);
@ -438,6 +447,10 @@ details[open] .summary::after {
flex: 1;
}
.progress-bar-mini > .progress-value {
--half-border-radius: .2rem;
}
.progress-value:first-child {
border-top-left-radius: var(--half-border-radius);
}
@ -626,6 +639,7 @@ details[open] .summary::after {
.gap-55 { gap: 5.5rem; }
.margin-left-auto { margin-left: auto; }
.margin-inline-auto { margin-inline: auto; }
.margin-top-2 { margin-top: 0.2rem; }
.margin-top-3 { margin-top: 0.3rem; }
.margin-top-5 { margin-top: 0.5rem; }
.margin-top-7 { margin-top: 0.7rem; }

@ -13,7 +13,6 @@
@import "widget-videos.css";
@import "widget-weather.css";
@import "widget-todo.css";
@import "forum-posts.css";
.widget-error-header {

@ -35,26 +35,8 @@ var globalTemplateFunctions = template.FuncMap{
},
"dynamicRelativeTimeAttrs": dynamicRelativeTimeAttrs,
"formatServerMegabytes": func(mb uint64) template.HTML {
var value string
var label string
if mb < 1_000 {
value = strconv.FormatUint(mb, 10)
label = "MB"
} else if mb < 1_000_000 {
if mb < 10_000 {
value = fmt.Sprintf("%.1f", float64(mb)/1_000)
} else {
value = strconv.FormatUint(mb/1_000, 10)
}
label = "GB"
} else {
value = fmt.Sprintf("%.1f", float64(mb)/1_000_000)
label = "TB"
}
return template.HTML(value + ` <span class="color-base size-h5">` + label + `</span>`)
value, unit := formatBytes(mb * 1000 * 1000)
return template.HTML(value + ` <span class="color-base size-h5">` + unit + `</span>`)
},
}
@ -89,3 +71,41 @@ func formatApproxNumber(count int) string {
func dynamicRelativeTimeAttrs(t interface{ Unix() int64 }) template.HTMLAttr {
return template.HTMLAttr(`data-dynamic-relative-time="` + strconv.FormatInt(t.Unix(), 10) + `"`)
}
func formatBytes(bytes uint64) (value, unit string) {
const oneKB = 1000
const oneMB = oneKB * 1000
const oneGB = oneMB * 1000
const oneTB = oneGB * 1000
if bytes < oneKB {
value = strconv.FormatUint(bytes, 10)
unit = "B"
} else if bytes < oneMB {
if bytes < 10*oneKB {
value = fmt.Sprintf("%.1f", float64(bytes)/oneKB)
} else {
value = strconv.FormatUint(bytes/oneKB, 10)
}
unit = "KB"
} else if bytes < oneGB {
if bytes < 10*oneMB {
value = fmt.Sprintf("%.1f", float64(bytes)/oneMB)
} else {
value = strconv.FormatUint(bytes/(oneMB), 10)
}
unit = "MB"
} else if bytes < oneTB {
if bytes < 10*oneGB {
value = fmt.Sprintf("%.1f", float64(bytes)/oneGB)
} else {
value = strconv.FormatUint(bytes/oneGB, 10)
}
unit = "GB"
} else {
value = fmt.Sprintf("%.1f", float64(bytes)/oneTB)
unit = "TB"
}
return value, unit
}

@ -0,0 +1,33 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
{{- if .Torrents }}
<ul class="list list-gap-20 collapsible-container" data-collapse-after="5">
{{- range .Torrents }}
<li>
<div class="size-title-dynamic text-truncate{{ if not .Downloaded }} color-highlight{{ end }}">{{ .Name }}</div>
<ul class="list-horizontal-text horizontal-text-gap-5 text-compact margin-top-2">
{{- if not .Downloaded }}
<li>{{ .ProgressFormatted }}</li>
{{- else }}
<li title="{{ .StateDescription }}">{{ .State }}</li>
{{- end }}
{{- if .DownSpeedFormatted }}
<li>{{ .DownSpeedFormatted }}/s</li>
{{- end }}
{{- if eq .State "Downloading" }}
<li>{{ .ETAFormatted }} ETA</li>
{{- else if not .Downloaded }}
<li title="{{ .StateDescription }}">{{ .State }}</li>
{{- end }}
</ul>
<div class="progress-bar progress-bar-mini margin-top-5">
<div class="progress-value" style="--percent: {{ .Progress }};"></div>
</div>
</li>
{{- end }}
</ul>
{{- else }}
<div class="text-center">No active torrents</div>
{{- end }}
{{ end }}

@ -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,7 +19,7 @@ type repositoryWidget struct {
PullRequestsLimit int `yaml:"pull-requests-limit"`
IssuesLimit int `yaml:"issues-limit"`
CommitsLimit int `yaml:"commits-limit"`
ExcludeDraftPRs bool `yaml:"exclude-draft-prs"`
ExcludeDraftPRs bool `yaml:"exclude-draft-pull-requests"`
Repository repository `yaml:"-"`
}
@ -113,23 +113,18 @@ type gitHubCommitResponseJson struct {
} `json:"commit"`
}
func buildPRQuery(repo string, excludeDraftPRs bool) string {
query := fmt.Sprintf("is:pr+is:open+repo:%s", repo)
if excludeDraftPRs {
query += "+-is:draft"
}
return query
}
func fetchRepositoryDetailsFromGithub(repo string, token string, maxPRs int, maxIssues int, maxCommits int, excludeDraftPRs bool) (repository, error) {
repositoryRequest, err := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s", repo), nil)
if err != nil {
return repository{}, fmt.Errorf("%w: could not create request with repository: %v", errNoContent, err)
}
prQuery := buildPRQuery(repo, excludeDraftPRs)
RRsQuery := fmt.Sprintf("is:pr+is:open+repo:%s", repo)
if excludeDraftPRs {
RRsQuery += "+-is:draft"
}
PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=%s&per_page=%d", prQuery, maxPRs), nil)
PRsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=%s&per_page=%d", RRsQuery, maxPRs), nil)
issuesRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/search/issues?q=is:issue+is:open+repo:%s&per_page=%d", repo, maxIssues), nil)
CommitsRequest, _ := http.NewRequest("GET", fmt.Sprintf("https://api.github.com/repos/%s/commits?per_page=%d", repo, maxCommits), nil)

@ -0,0 +1,339 @@
package glance
import (
"context"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"time"
)
var torrentsWidgetTemplate = mustParseTemplate("torrents.html", "widget-base.html")
type torrentsWidget struct {
widgetBase `yaml:",inline"`
URL string `yaml:"url"`
AllowInsecure bool `yaml:"allow-insecure"`
Username string `yaml:"username"`
Password string `yaml:"password"`
Limit int `yaml:"limit"`
CollapseAfter int `yaml:"collapse-after"`
Client string `yaml:"client"`
SortBy sortableFields[torrent] `yaml:"sort-by"`
Torrents []torrent `yaml:"-"`
sessionID string
}
func (widget *torrentsWidget) initialize() error {
widget.
withTitle("Torrents").
withTitleURL(widget.URL).
withCacheDuration(time.Second * 5)
if widget.URL == "" {
return errors.New("URL is required")
}
if _, err := url.Parse(widget.URL); err != nil {
return fmt.Errorf("invalid URL: %v", err)
}
widget.URL = strings.TrimSuffix(widget.URL, "/")
if widget.Limit <= 0 {
widget.Limit = 10
}
if widget.CollapseAfter == 0 || widget.CollapseAfter < -1 {
widget.CollapseAfter = 5
}
if widget.Client == "" {
widget.Client = "qbittorrent"
}
if !slices.Contains([]string{"qbittorrent"}, 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
}
func (widget *torrentsWidget) update(ctx context.Context) {
var torrents []torrent
var err error
switch widget.Client {
case "qbittorrent":
torrents, err = widget.fetchQbtTorrents()
default:
err = fmt.Errorf("unsupported client: %s", widget.Client)
}
if !widget.canContinueUpdateAfterHandlingErr(err) {
return
}
widget.SortBy.Apply(torrents)
if len(torrents) > widget.Limit {
torrents = torrents[:widget.Limit]
}
widget.Torrents = torrents
}
func (widget *torrentsWidget) Render() template.HTML {
return widget.renderTemplate(widget, torrentsWidgetTemplate)
}
const (
torrentStatusDownloading = "Downloading"
torrentStatusDownloaded = "Downloaded"
torrentStatusSeeding = "Seeding"
torrentStatusPaused = "Paused"
torrentStatusStalled = "Stalled"
torrentStatusError = "Error"
torrentStatusOther = "Other"
)
// States taken from https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1)#torrent-management
var qbittorrentStates = map[string][2]string{
// Downloading states
"downloading": {torrentStatusDownloading, "Torrent is being downloaded and data is being transferred"},
"metaDL": {torrentStatusDownloading, "Torrent has just started downloading and is fetching metadata"},
"forcedDL": {torrentStatusDownloading, "Torrent is forced to download, ignoring queue limit"},
"allocating": {torrentStatusDownloading, "Torrent is allocating disk space for download"},
// Downloaded/Seeding states
"checkingUP": {torrentStatusDownloaded, "Torrent has finished downloading and is being checked"},
"uploading": {torrentStatusSeeding, "Torrent is being seeded and data is being transferred"},
"stalledUP": {torrentStatusSeeding, "Torrent is being seeded, but no connections were made"},
"forcedUP": {torrentStatusSeeding, "Torrent is forced to upload, ignoring queue limit"},
// Stopped/Paused states
"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"},
// Error states
"error": {torrentStatusError, "An error occurred, applies to paused torrents"},
"missingFiles": {torrentStatusError, "Torrent data files are missing"},
// Other states
"checkingDL": {torrentStatusOther, "Same as checkingUP, but torrent has not finished downloading"},
"checkingResumeData": {torrentStatusOther, "Checking resume data on qBittorrent startup"},
"moving": {torrentStatusOther, "Torrent is moving to another location"},
"unknown": {torrentStatusOther, "Unknown status"},
}
type torrent struct {
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 {
if seconds < 60 {
return fmt.Sprintf("%ds", seconds)
} else if seconds < 60*60 {
return fmt.Sprintf("%dm", seconds/60)
} else if seconds < 60*60*24 {
return fmt.Sprintf("%dh", seconds/(60*60))
} else if seconds < 60*60*24*7 {
return fmt.Sprintf("%dd", seconds/(60*60*24))
}
return fmt.Sprintf("%dw", seconds/(60*60*24*7))
}
func (widget *torrentsWidget) fetchQbtTorrents() ([]torrent, error) {
if widget.sessionID == "" {
if err := widget.fetchQbtSessionID(); err != nil {
return nil, fmt.Errorf("fetching qBittorrent session ID: %v", err)
}
}
torrents, refetchSID, err := widget._fetchQbtTorrents()
if err != nil {
if refetchSID {
if err := widget.fetchQbtSessionID(); err != nil {
return nil, fmt.Errorf("refetching qBittorrent session ID: %v", err)
}
torrents, _, err = widget._fetchQbtTorrents()
if err != nil {
return nil, fmt.Errorf("refetching qBittorrent torrents: %v", err)
}
} else {
return nil, fmt.Errorf("fetching qBittorrent torrents: %v", err)
}
}
return torrents, nil
}
func (widget *torrentsWidget) _fetchQbtTorrents() ([]torrent, bool, error) {
params := url.Values{}
params.Set("limit", strconv.Itoa(widget.Limit))
params.Set("sort", "dlspeed")
params.Set("reverse", "true")
requestURL := fmt.Sprintf("%s%s?%s", widget.URL, "/api/v2/torrents/info", params.Encode())
req, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
return nil, false, fmt.Errorf("creating torrents request: %v", err)
}
req.Header.Set("Referer", widget.URL)
req.AddCookie(&http.Cookie{Name: "SID", Value: widget.sessionID})
client := ternary(widget.AllowInsecure, defaultInsecureHTTPClient, defaultHTTPClient)
resp, err := client.Do(req)
if err != nil {
return nil, false, fmt.Errorf("torrents request: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
// QBittorrent seems to return a 403 if the session ID is invalid or expired.
refetch := resp.StatusCode == http.StatusForbidden
return nil, refetch, fmt.Errorf("torrents request failed with status %d: %s", resp.StatusCode, string(body))
}
type qbTorrent struct {
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
if err := json.Unmarshal(body, &rawTorrents); err != nil {
return nil, true, fmt.Errorf("decoding torrents response: %v", err)
}
torrents := make([]torrent, len(rawTorrents))
for i, raw := range rawTorrents {
state := raw.State
stateDescription := "Unknown state"
if mappedState, exists := qbittorrentStates[raw.State]; exists {
state = mappedState[0]
stateDescription = mappedState[1]
}
torrents[i] = torrent{
Name: raw.Name,
Progress: raw.Progress * 100,
Downloaded: raw.Progress >= 1.0,
ProgressFormatted: fmt.Sprintf("%.1f%%", raw.Progress*100),
State: state,
StateDescription: stateDescription,
ETAFormatted: widget.formatETA(raw.ETA),
DownSpeed: raw.DownSpeed,
UpSpeed: raw.UpSpeed,
}
if raw.DownSpeed > 0 {
value, unit := formatBytes(raw.DownSpeed)
torrents[i].DownSpeedFormatted = fmt.Sprintf("%s %s", value, unit)
}
}
return torrents, false, nil
}
func (widget *torrentsWidget) fetchQbtSessionID() error {
loginData := url.Values{}
loginData.Set("username", widget.Username)
loginData.Set("password", widget.Password)
req, err := http.NewRequest(
"POST",
fmt.Sprintf("%s/api/v2/auth/login", widget.URL),
strings.NewReader(loginData.Encode()),
)
if err != nil {
return fmt.Errorf("creating login request: %v", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Referer", widget.URL)
client := ternary(widget.AllowInsecure, defaultInsecureHTTPClient, defaultHTTPClient)
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("login request: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("login failed with status %d: %s", resp.StatusCode, string(body))
}
cookies := resp.Cookies()
if len(cookies) == 0 {
return errors.New("no session cookie received, maybe the username or password is incorrect?")
}
for _, cookie := range cookies {
if cookie.Name == "SID" {
widget.sessionID = cookie.Value
}
}
if widget.sessionID == "" {
return errors.New("session ID not found in cookies")
}
return nil
}

@ -79,6 +79,8 @@ func newWidget(widgetType string) (widget, error) {
w = &dockerContainersWidget{}
case "server-stats":
w = &serverStatsWidget{}
case "torrents":
w = &torrentsWidget{}
case "to-do":
w = &todoWidget{}
default: