mirror of https://github.com/glanceapp/glance.git
Refactor into a unified torrents widget
parent
7a1fd8acf9
commit
6c4a5e8337
@ -1,3 +0,0 @@
|
||||
.qbittorrent-torrent + .qbittorrent-torrent {
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue