Refactor into a unified torrents widget

pull/543/head
Svilen Markov 2025-08-26 02:26:57 +07:00
parent 7a1fd8acf9
commit 6c4a5e8337
9 changed files with 391 additions and 236 deletions

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

@ -1,3 +0,0 @@
.qbittorrent-torrent + .qbittorrent-torrent {
margin-top: 1.2rem;
}

@ -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 {

@ -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] + ` <span class="color-base size-h5">` + parts[1] + `</span>`)
}
return template.HTML(formatted)
value, unit := formatBytes(mb * 1000 * 1000)
return template.HTML(value + ` <span class="color-base size-h5">` + unit + `</span>`)
},
}
@ -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
}

@ -1,32 +0,0 @@
{{ template "widget-base.html" . }}
{{ define "widget-content" }}
<div class="qbittorrent-torrents">
{{- if .Torrents }}
{{- range .Torrents }}
<div class="qbittorrent-torrent">
<div class="qbittorrent-torrent-header text-truncate">
<span class="qbittorrent-torrent-name">{{ .Name }}</span>
</div>
<div class="qbittorrent-torrent-progress flex items-center gap-10">
<div class="progress-bar flex-1">
<div class="progress-value" style="--percent: {{ (multiply .Progress 100) }};"></div>
</div>
<span class="qbittorrent-progress-text color-highlight text-very-compact">{{ printf "%.1f" (multiply
.Progress 100) }}
<span class="color-base">%</span></span>
</div>
<div class="qbittorrent-torrent-info">
{{- if eq .Progress 1.0 }}
<span class="qbittorrent-torrent-completed color-primary">Completed</span>
{{- else }}
<span class="qbittorrent-torrent-speed">{{ .Speed | formatBytes }}/s</span>
{{- end }}
</div>
</div>
{{- end }}
{{- else }}
<div class="text-center color-subdue">No active torrents</div>
{{- end }}
</div>
{{ end }}

@ -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 .SpeedFormatted }}
<li>{{ .SpeedFormatted }}/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 }}

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

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

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