From b46cd6601c8370625081a8c0f71fcaa84ffc168d Mon Sep 17 00:00:00 2001 From: ThiemeH Date: Tue, 25 Mar 2025 02:12:25 +0100 Subject: [PATCH 1/5] Added qBittorrent --- .../glance/static/css/widget-qbittorrent.css | 3 + internal/glance/templates.go | 23 +++ internal/glance/templates/qbittorrent.html | 28 ++++ internal/glance/widget-qbittorrent.go | 153 ++++++++++++++++++ internal/glance/widget.go | 2 + 5 files changed, 209 insertions(+) create mode 100644 internal/glance/static/css/widget-qbittorrent.css create mode 100644 internal/glance/templates/qbittorrent.html create mode 100644 internal/glance/widget-qbittorrent.go diff --git a/internal/glance/static/css/widget-qbittorrent.css b/internal/glance/static/css/widget-qbittorrent.css new file mode 100644 index 0000000..28f11f5 --- /dev/null +++ b/internal/glance/static/css/widget-qbittorrent.css @@ -0,0 +1,3 @@ +.qbittorrent-torrent + .qbittorrent-torrent { + margin-top: 1.2rem; +} diff --git a/internal/glance/templates.go b/internal/glance/templates.go index 699772d..14eb368 100644 --- a/internal/glance/templates.go +++ b/internal/glance/templates.go @@ -24,6 +24,7 @@ var globalTemplateFunctions = template.FuncMap{ "absInt": func(i int) int { return int(math.Abs(float64(i))) }, + "multiply": multiply, "formatPrice": func(price float64) string { return intl.Sprintf("%.2f", price) }, @@ -86,3 +87,25 @@ 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 multiply(a, b interface{}) float64 { + var result float64 + + switch v := a.(type) { + case int: + result = float64(v) + case float64: + result = v + default: + panic("Unsupported type for 'a', only int and float64 are supported") + } + + switch v := b.(type) { + case int: + return result * float64(v) + case float64: + return result * v + default: + panic("Unsupported type for 'b', only int and float64 are supported") + } +} diff --git a/internal/glance/templates/qbittorrent.html b/internal/glance/templates/qbittorrent.html new file mode 100644 index 0000000..51721e1 --- /dev/null +++ b/internal/glance/templates/qbittorrent.html @@ -0,0 +1,28 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} +
+ {{- if .Torrents }} + {{- range .Torrents }} +
+
+ {{ .Name }} +
+
+
+
+
+ {{ printf "%.1f" (multiply + .Progress 100) }} + % +
+
+ {{ .Speed }}/s +
+
+ {{- end }} + {{- else }} +
No active torrents
+ {{- end }} +
+{{ end }} diff --git a/internal/glance/widget-qbittorrent.go b/internal/glance/widget-qbittorrent.go new file mode 100644 index 0000000..1235d75 --- /dev/null +++ b/internal/glance/widget-qbittorrent.go @@ -0,0 +1,153 @@ +package glance + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "log/slog" + "net/http" + "net/http/cookiejar" + "net/url" + "sort" + "strings" + "time" +) + +const ( + qBittorrentAPIPrefix = "/api/v2" + qBittorrentLoginPath = qBittorrentAPIPrefix + "/auth/login" + qBittorrentTorrentsPath = qBittorrentAPIPrefix + "/torrents/info" +) + +var qbittorrentWidgetTemplate = mustParseTemplate("qbittorrent.html", "widget-base.html") + +type qbittorrentWidget struct { + widgetBase `yaml:",inline"` + URL string `yaml:"url"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Limit int `yaml:"limit"` + Torrents []qbittorrentTorrent `yaml:"-"` + client *http.Client `yaml:"-"` +} + +type qbittorrentTorrent struct { + Name string `json:"name"` + Progress float64 `json:"progress"` + State string `json:"state"` + Size int64 `json:"size"` + Downloaded int64 `json:"downloaded"` + Speed uint64 `json:"dlspeed"` +} + +func (widget *qbittorrentWidget) initialize() error { + widget. + withTitle("qBittorrent"). + 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: %w", err) + } + + if widget.Limit <= 0 { + widget.Limit = 5 + } + + jar, err := cookiejar.New(nil) + if err != nil { + return fmt.Errorf("error creating cookie jar: %w", err) + } + + widget.client = &http.Client{Jar: jar} + + if err := widget.login(); err != nil { + return fmt.Errorf("login failed: %w", err) + } + + return nil +} + +func (widget *qbittorrentWidget) login() error { + loginData := url.Values{} + loginData.Set("username", widget.Username) + loginData.Set("password", widget.Password) + + req, err := http.NewRequest("POST", widget.URL+qBittorrentLoginPath, strings.NewReader(loginData.Encode())) + if err != nil { + return fmt.Errorf("creating login request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Referer", widget.URL) + + resp, err := widget.client.Do(req) + if err != nil { + return fmt.Errorf("login request failed: %w", err) + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("login failed with status %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + +func (widget *qbittorrentWidget) update(ctx context.Context) { + slog.Info("Updating qBittorrent widget", "url", widget.URL) + + req, err := http.NewRequestWithContext(ctx, "GET", widget.URL+qBittorrentTorrentsPath, nil) + if err != nil { + widget.withError(fmt.Errorf("creating torrents request: %w", err)) + return + } + + req.Header.Set("Referer", widget.URL) + + resp, err := widget.client.Do(req) + if err != nil { + widget.withError(fmt.Errorf("torrents request failed: %w", err)) + return + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + widget.withError(fmt.Errorf("torrents request failed with status %d: %s", resp.StatusCode, string(body))) + return + } + + var torrents []qbittorrentTorrent + if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil { + widget.withError(fmt.Errorf("decoding torrents response: %w", err)) + return + } + + sort.Slice(torrents, func(i, j int) bool { + return torrents[i].Progress > torrents[j].Progress + }) + + if len(torrents) > widget.Limit { + torrents = torrents[:widget.Limit] + } + + widget.Torrents = torrents + widget.withError(nil) +} + +func (widget *qbittorrentWidget) Render() template.HTML { + return widget.renderTemplate(widget, qbittorrentWidgetTemplate) +} diff --git a/internal/glance/widget.go b/internal/glance/widget.go index 7c30183..a46da66 100644 --- a/internal/glance/widget.go +++ b/internal/glance/widget.go @@ -79,6 +79,8 @@ func newWidget(widgetType string) (widget, error) { w = &dockerContainersWidget{} case "server-stats": w = &serverStatsWidget{} + case "qbittorrent": + w = &qbittorrentWidget{} default: return nil, fmt.Errorf("unknown widget type: %s", widgetType) } From e14569821a0efffcba5ca9ac97f6bc0af7872d3c Mon Sep 17 00:00:00 2001 From: ThiemeH Date: Mon, 31 Mar 2025 21:33:43 +0200 Subject: [PATCH 2/5] Fixed bytes formatting --- internal/glance/templates.go | 62 +++++++++++++++------- internal/glance/templates/qbittorrent.html | 4 +- internal/glance/widget-qbittorrent.go | 3 -- 3 files changed, 45 insertions(+), 24 deletions(-) diff --git a/internal/glance/templates.go b/internal/glance/templates.go index 14eb368..1633c30 100644 --- a/internal/glance/templates.go +++ b/internal/glance/templates.go @@ -5,6 +5,7 @@ import ( "html/template" "math" "strconv" + "strings" "golang.org/x/text/language" "golang.org/x/text/message" @@ -32,27 +33,14 @@ var globalTemplateFunctions = template.FuncMap{ return intl.Sprintf("%."+strconv.Itoa(precision)+"f", price) }, "dynamicRelativeTimeAttrs": dynamicRelativeTimeAttrs, + "formatBytes": formatBytes, "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" + formatted := formatBytes(mb * 1024 * 1024) + parts := strings.Split(formatted, " ") + if len(parts) == 2 { + return template.HTML(parts[0] + ` ` + parts[1] + ``) } - - return template.HTML(value + ` ` + label + ``) + return template.HTML(formatted) }, } @@ -109,3 +97,39 @@ func multiply(a, b interface{}) float64 { panic("Unsupported type for 'b', only int and float64 are supported") } } + +func formatBytes(bytes uint64) string { + var value string + var unit string + + if bytes < 1024 { + value = strconv.FormatUint(bytes, 10) + unit = "B" + } else if bytes < 1024*1024 { + if bytes < 10*1024 { + value = fmt.Sprintf("%.1f", float64(bytes)/1024) + } else { + value = strconv.FormatUint(bytes/1024, 10) + } + unit = "KB" + } else if bytes < 1024*1024*1024 { + if bytes < 10*1024*1024 { + value = fmt.Sprintf("%.1f", float64(bytes)/(1024*1024)) + } else { + value = strconv.FormatUint(bytes/(1024*1024), 10) + } + unit = "MB" + } else if bytes < 1024*1024*1024*1024 { + if bytes < 10*1024*1024*1024 { + value = fmt.Sprintf("%.1f", float64(bytes)/(1024*1024*1024)) + } else { + value = strconv.FormatUint(bytes/(1024*1024*1024), 10) + } + unit = "GB" + } else { + value = fmt.Sprintf("%.1f", float64(bytes)/(1024*1024*1024*1024)) + unit = "TB" + } + + return value + " " + unit +} diff --git a/internal/glance/templates/qbittorrent.html b/internal/glance/templates/qbittorrent.html index 51721e1..bc53270 100644 --- a/internal/glance/templates/qbittorrent.html +++ b/internal/glance/templates/qbittorrent.html @@ -10,14 +10,14 @@
-
+
{{ printf "%.1f" (multiply .Progress 100) }} %
- {{ .Speed }}/s + {{ .Speed | formatBytes }}/s
{{- end }} diff --git a/internal/glance/widget-qbittorrent.go b/internal/glance/widget-qbittorrent.go index 1235d75..99f490d 100644 --- a/internal/glance/widget-qbittorrent.go +++ b/internal/glance/widget-qbittorrent.go @@ -7,7 +7,6 @@ import ( "fmt" "html/template" "io" - "log/slog" "net/http" "net/http/cookiejar" "net/url" @@ -105,8 +104,6 @@ func (widget *qbittorrentWidget) login() error { } func (widget *qbittorrentWidget) update(ctx context.Context) { - slog.Info("Updating qBittorrent widget", "url", widget.URL) - req, err := http.NewRequestWithContext(ctx, "GET", widget.URL+qBittorrentTorrentsPath, nil) if err != nil { widget.withError(fmt.Errorf("creating torrents request: %w", err)) From 38bdc69e671df715cc305123e1895b3513316846 Mon Sep 17 00:00:00 2001 From: ThiemeH Date: Mon, 31 Mar 2025 21:37:24 +0200 Subject: [PATCH 3/5] Added completed text --- internal/glance/templates/qbittorrent.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/glance/templates/qbittorrent.html b/internal/glance/templates/qbittorrent.html index bc53270..9ac6d71 100644 --- a/internal/glance/templates/qbittorrent.html +++ b/internal/glance/templates/qbittorrent.html @@ -17,7 +17,11 @@ %
+ {{- if eq .Progress 1.0 }} + Completed + {{- else }} {{ .Speed | formatBytes }}/s + {{- end }}
{{- end }} From 7a1fd8acf97ab95ba9474fae26cc4af11ed9f64e Mon Sep 17 00:00:00 2001 From: ThiemeH Date: Mon, 31 Mar 2025 21:46:19 +0200 Subject: [PATCH 4/5] Fixed text truncate, req params and css --- internal/glance/static/css/widgets.css | 1 + internal/glance/templates/qbittorrent.html | 4 ++-- internal/glance/widget-qbittorrent.go | 18 ++++++++---------- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/internal/glance/static/css/widgets.css b/internal/glance/static/css/widgets.css index 9c9e8ee..ac63040 100644 --- a/internal/glance/static/css/widgets.css +++ b/internal/glance/static/css/widgets.css @@ -6,6 +6,7 @@ @import "widget-group.css"; @import "widget-markets.css"; @import "widget-monitor.css"; +@import "widget-qbittorrent.css"; @import "widget-reddit.css"; @import "widget-releases.css"; @import "widget-rss.css"; diff --git a/internal/glance/templates/qbittorrent.html b/internal/glance/templates/qbittorrent.html index 9ac6d71..ce969e2 100644 --- a/internal/glance/templates/qbittorrent.html +++ b/internal/glance/templates/qbittorrent.html @@ -5,8 +5,8 @@ {{- if .Torrents }} {{- range .Torrents }}
-
- {{ .Name }} +
+ {{ .Name }}
diff --git a/internal/glance/widget-qbittorrent.go b/internal/glance/widget-qbittorrent.go index 99f490d..03d772c 100644 --- a/internal/glance/widget-qbittorrent.go +++ b/internal/glance/widget-qbittorrent.go @@ -10,7 +10,7 @@ import ( "net/http" "net/http/cookiejar" "net/url" - "sort" + "strconv" "strings" "time" ) @@ -104,7 +104,13 @@ func (widget *qbittorrentWidget) login() error { } func (widget *qbittorrentWidget) update(ctx context.Context) { - req, err := http.NewRequestWithContext(ctx, "GET", widget.URL+qBittorrentTorrentsPath, nil) + params := url.Values{} + params.Set("limit", strconv.Itoa(widget.Limit)) + params.Set("sort", "dlspeed") + params.Set("reverse", "true") + + requestURL := widget.URL + qBittorrentTorrentsPath + "?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil) if err != nil { widget.withError(fmt.Errorf("creating torrents request: %w", err)) return @@ -133,14 +139,6 @@ func (widget *qbittorrentWidget) update(ctx context.Context) { return } - sort.Slice(torrents, func(i, j int) bool { - return torrents[i].Progress > torrents[j].Progress - }) - - if len(torrents) > widget.Limit { - torrents = torrents[:widget.Limit] - } - widget.Torrents = torrents widget.withError(nil) } From 6c4a5e83377250f243fc9575ad442ad1006459d9 Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Tue, 26 Aug 2025 02:26:57 +0100 Subject: [PATCH 5/5] Refactor into a unified torrents widget --- internal/glance/static/css/utils.css | 14 + .../glance/static/css/widget-qbittorrent.css | 3 - internal/glance/static/css/widgets.css | 2 - internal/glance/templates.go | 71 ++-- internal/glance/templates/qbittorrent.html | 32 -- internal/glance/templates/torrents.html | 33 ++ internal/glance/widget-qbittorrent.go | 148 -------- internal/glance/widget-torrents.go | 320 ++++++++++++++++++ internal/glance/widget.go | 4 +- 9 files changed, 391 insertions(+), 236 deletions(-) delete mode 100644 internal/glance/static/css/widget-qbittorrent.css delete mode 100644 internal/glance/templates/qbittorrent.html create mode 100644 internal/glance/templates/torrents.html delete mode 100644 internal/glance/widget-qbittorrent.go create mode 100644 internal/glance/widget-torrents.go diff --git a/internal/glance/static/css/utils.css b/internal/glance/static/css/utils.css index 42aef5a..9962528 100644 --- a/internal/glance/static/css/utils.css +++ b/internal/glance/static/css/utils.css @@ -38,6 +38,10 @@ top: 0.1rem; } +.horizontal-text-gap-5 > *:not(:last-child)::after { + margin: 0 0.5rem; +} + .summary { width: 100%; cursor: pointer; @@ -403,6 +407,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); @@ -417,6 +426,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); } @@ -522,6 +535,7 @@ details[open] .summary::after { .gap-45 { gap: 4.5rem; } .gap-55 { gap: 5.5rem; } .margin-left-auto { margin-left: 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; } diff --git a/internal/glance/static/css/widget-qbittorrent.css b/internal/glance/static/css/widget-qbittorrent.css deleted file mode 100644 index 28f11f5..0000000 --- a/internal/glance/static/css/widget-qbittorrent.css +++ /dev/null @@ -1,3 +0,0 @@ -.qbittorrent-torrent + .qbittorrent-torrent { - margin-top: 1.2rem; -} diff --git a/internal/glance/static/css/widgets.css b/internal/glance/static/css/widgets.css index ac63040..d87ce9c 100644 --- a/internal/glance/static/css/widgets.css +++ b/internal/glance/static/css/widgets.css @@ -6,7 +6,6 @@ @import "widget-group.css"; @import "widget-markets.css"; @import "widget-monitor.css"; -@import "widget-qbittorrent.css"; @import "widget-reddit.css"; @import "widget-releases.css"; @import "widget-rss.css"; @@ -15,7 +14,6 @@ @import "widget-twitch.css"; @import "widget-videos.css"; @import "widget-weather.css"; - @import "forum-posts.css"; .widget-error-header { diff --git a/internal/glance/templates.go b/internal/glance/templates.go index 1633c30..733b5fe 100644 --- a/internal/glance/templates.go +++ b/internal/glance/templates.go @@ -5,7 +5,6 @@ import ( "html/template" "math" "strconv" - "strings" "golang.org/x/text/language" "golang.org/x/text/message" @@ -25,7 +24,6 @@ var globalTemplateFunctions = template.FuncMap{ "absInt": func(i int) int { return int(math.Abs(float64(i))) }, - "multiply": multiply, "formatPrice": func(price float64) string { return intl.Sprintf("%.2f", price) }, @@ -33,14 +31,9 @@ var globalTemplateFunctions = template.FuncMap{ return intl.Sprintf("%."+strconv.Itoa(precision)+"f", price) }, "dynamicRelativeTimeAttrs": dynamicRelativeTimeAttrs, - "formatBytes": formatBytes, "formatServerMegabytes": func(mb uint64) template.HTML { - formatted := formatBytes(mb * 1024 * 1024) - parts := strings.Split(formatted, " ") - if len(parts) == 2 { - return template.HTML(parts[0] + ` ` + parts[1] + ``) - } - return template.HTML(formatted) + value, unit := formatBytes(mb * 1000 * 1000) + return template.HTML(value + ` ` + unit + ``) }, } @@ -76,60 +69,40 @@ func dynamicRelativeTimeAttrs(t interface{ Unix() int64 }) template.HTMLAttr { return template.HTMLAttr(`data-dynamic-relative-time="` + strconv.FormatInt(t.Unix(), 10) + `"`) } -func multiply(a, b interface{}) float64 { - var result float64 - - switch v := a.(type) { - case int: - result = float64(v) - case float64: - result = v - default: - panic("Unsupported type for 'a', only int and float64 are supported") - } - - switch v := b.(type) { - case int: - return result * float64(v) - case float64: - return result * v - default: - panic("Unsupported type for 'b', only int and float64 are supported") - } -} - -func formatBytes(bytes uint64) string { - var value string - var unit string +func formatBytes(bytes uint64) (value, unit string) { + const oneKB = 1000 + const oneMB = oneKB * 1000 + const oneGB = oneMB * 1000 + const oneTB = oneGB * 1000 - if bytes < 1024 { + if bytes < oneKB { value = strconv.FormatUint(bytes, 10) unit = "B" - } else if bytes < 1024*1024 { - if bytes < 10*1024 { - value = fmt.Sprintf("%.1f", float64(bytes)/1024) + } else if bytes < oneMB { + if bytes < 10*oneKB { + value = fmt.Sprintf("%.1f", float64(bytes)/oneKB) } else { - value = strconv.FormatUint(bytes/1024, 10) + value = strconv.FormatUint(bytes/oneKB, 10) } unit = "KB" - } else if bytes < 1024*1024*1024 { - if bytes < 10*1024*1024 { - value = fmt.Sprintf("%.1f", float64(bytes)/(1024*1024)) + } else if bytes < oneGB { + if bytes < 10*oneMB { + value = fmt.Sprintf("%.1f", float64(bytes)/oneMB) } else { - value = strconv.FormatUint(bytes/(1024*1024), 10) + value = strconv.FormatUint(bytes/(oneMB), 10) } unit = "MB" - } else if bytes < 1024*1024*1024*1024 { - if bytes < 10*1024*1024*1024 { - value = fmt.Sprintf("%.1f", float64(bytes)/(1024*1024*1024)) + } else if bytes < oneTB { + if bytes < 10*oneGB { + value = fmt.Sprintf("%.1f", float64(bytes)/oneGB) } else { - value = strconv.FormatUint(bytes/(1024*1024*1024), 10) + value = strconv.FormatUint(bytes/oneGB, 10) } unit = "GB" } else { - value = fmt.Sprintf("%.1f", float64(bytes)/(1024*1024*1024*1024)) + value = fmt.Sprintf("%.1f", float64(bytes)/oneTB) unit = "TB" } - return value + " " + unit + return value, unit } diff --git a/internal/glance/templates/qbittorrent.html b/internal/glance/templates/qbittorrent.html deleted file mode 100644 index ce969e2..0000000 --- a/internal/glance/templates/qbittorrent.html +++ /dev/null @@ -1,32 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} -
- {{- if .Torrents }} - {{- range .Torrents }} -
-
- {{ .Name }} -
-
-
-
-
- {{ printf "%.1f" (multiply - .Progress 100) }} - % -
-
- {{- if eq .Progress 1.0 }} - Completed - {{- else }} - {{ .Speed | formatBytes }}/s - {{- end }} -
-
- {{- end }} - {{- else }} -
No active torrents
- {{- end }} -
-{{ end }} diff --git a/internal/glance/templates/torrents.html b/internal/glance/templates/torrents.html new file mode 100644 index 0000000..eb51408 --- /dev/null +++ b/internal/glance/templates/torrents.html @@ -0,0 +1,33 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} +{{- if .Torrents }} +
    +{{- range .Torrents }} +
  • +
    {{ .Name }}
    +
      + {{- if not .Downloaded }} +
    • {{ .ProgressFormatted }}
    • + {{- else }} +
    • {{ .State }}
    • + {{- end }} + {{- if .SpeedFormatted }} +
    • {{ .SpeedFormatted }}/s
    • + {{- end }} + {{- if eq .State "Downloading" }} +
    • {{ .ETAFormatted }} ETA
    • + {{- else if not .Downloaded }} +
    • {{ .State }}
    • + {{- end }} +
    +
    +
    +
    +
  • + {{- end }} +
+{{- else }} +
No active torrents
+{{- end }} +{{ end }} diff --git a/internal/glance/widget-qbittorrent.go b/internal/glance/widget-qbittorrent.go deleted file mode 100644 index 03d772c..0000000 --- a/internal/glance/widget-qbittorrent.go +++ /dev/null @@ -1,148 +0,0 @@ -package glance - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "html/template" - "io" - "net/http" - "net/http/cookiejar" - "net/url" - "strconv" - "strings" - "time" -) - -const ( - qBittorrentAPIPrefix = "/api/v2" - qBittorrentLoginPath = qBittorrentAPIPrefix + "/auth/login" - qBittorrentTorrentsPath = qBittorrentAPIPrefix + "/torrents/info" -) - -var qbittorrentWidgetTemplate = mustParseTemplate("qbittorrent.html", "widget-base.html") - -type qbittorrentWidget struct { - widgetBase `yaml:",inline"` - URL string `yaml:"url"` - Username string `yaml:"username"` - Password string `yaml:"password"` - Limit int `yaml:"limit"` - Torrents []qbittorrentTorrent `yaml:"-"` - client *http.Client `yaml:"-"` -} - -type qbittorrentTorrent struct { - Name string `json:"name"` - Progress float64 `json:"progress"` - State string `json:"state"` - Size int64 `json:"size"` - Downloaded int64 `json:"downloaded"` - Speed uint64 `json:"dlspeed"` -} - -func (widget *qbittorrentWidget) initialize() error { - widget. - withTitle("qBittorrent"). - 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: %w", err) - } - - if widget.Limit <= 0 { - widget.Limit = 5 - } - - jar, err := cookiejar.New(nil) - if err != nil { - return fmt.Errorf("error creating cookie jar: %w", err) - } - - widget.client = &http.Client{Jar: jar} - - if err := widget.login(); err != nil { - return fmt.Errorf("login failed: %w", err) - } - - return nil -} - -func (widget *qbittorrentWidget) login() error { - loginData := url.Values{} - loginData.Set("username", widget.Username) - loginData.Set("password", widget.Password) - - req, err := http.NewRequest("POST", widget.URL+qBittorrentLoginPath, strings.NewReader(loginData.Encode())) - if err != nil { - return fmt.Errorf("creating login request: %w", err) - } - - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Referer", widget.URL) - - resp, err := widget.client.Do(req) - if err != nil { - return fmt.Errorf("login request failed: %w", err) - } - defer func(Body io.ReadCloser) { - _ = Body.Close() - }(resp.Body) - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("login failed with status %d: %s", resp.StatusCode, string(body)) - } - - return nil -} - -func (widget *qbittorrentWidget) update(ctx context.Context) { - params := url.Values{} - params.Set("limit", strconv.Itoa(widget.Limit)) - params.Set("sort", "dlspeed") - params.Set("reverse", "true") - - requestURL := widget.URL + qBittorrentTorrentsPath + "?" + params.Encode() - req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil) - if err != nil { - widget.withError(fmt.Errorf("creating torrents request: %w", err)) - return - } - - req.Header.Set("Referer", widget.URL) - - resp, err := widget.client.Do(req) - if err != nil { - widget.withError(fmt.Errorf("torrents request failed: %w", err)) - return - } - defer func(Body io.ReadCloser) { - _ = Body.Close() - }(resp.Body) - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - widget.withError(fmt.Errorf("torrents request failed with status %d: %s", resp.StatusCode, string(body))) - return - } - - var torrents []qbittorrentTorrent - if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil { - widget.withError(fmt.Errorf("decoding torrents response: %w", err)) - return - } - - widget.Torrents = torrents - widget.withError(nil) -} - -func (widget *qbittorrentWidget) Render() template.HTML { - return widget.renderTemplate(widget, qbittorrentWidgetTemplate) -} diff --git a/internal/glance/widget-torrents.go b/internal/glance/widget-torrents.go new file mode 100644 index 0000000..66fbe6d --- /dev/null +++ b/internal/glance/widget-torrents.go @@ -0,0 +1,320 @@ +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"` + + Torrents []torrent `yaml:"-"` + + // QBittorrent client + sessionID string // session ID for authentication +} + +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) + } + + 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 + } + + // 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) + }) + + 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" + torrentStatusStopped = "Stopped" + 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": {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"}, + + // 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 + SpeedFormatted 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"` + Speed uint64 `json:"dlspeed"` + 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), + } + + if raw.Speed > 0 { + speedValue, speedUnit := formatBytes(raw.Speed) + torrents[i].SpeedFormatted = fmt.Sprintf("%s %s", speedValue, speedUnit) + } + } + + 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 { + fmt.Println(string(body)) + 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 +} diff --git a/internal/glance/widget.go b/internal/glance/widget.go index a46da66..0f87334 100644 --- a/internal/glance/widget.go +++ b/internal/glance/widget.go @@ -79,8 +79,8 @@ func newWidget(widgetType string) (widget, error) { w = &dockerContainersWidget{} case "server-stats": w = &serverStatsWidget{} - case "qbittorrent": - w = &qbittorrentWidget{} + case "torrents": + w = &torrentsWidget{} default: return nil, fmt.Errorf("unknown widget type: %s", widgetType) }