feat(downloads): show normalized task details

This commit is contained in:
saltbo
2026-06-03 11:12:56 -04:00
parent 912042e31e
commit f698e5a8a9
23 changed files with 5894 additions and 182 deletions
+776
View File
@@ -321,10 +321,166 @@
"type": "integer",
"format": "int64"
},
"errorMessage": {
"type": "string",
"nullable": true
},
"resultObjectId": {
"type": "string",
"nullable": true
},
"detail": {
"type": "object",
"nullable": true,
"properties": {
"engine": {
"type": "string",
"enum": [
"builtin",
"aria2",
"qbittorrent"
]
},
"phase": {
"type": "string",
"maxLength": 80
},
"message": {
"type": "string",
"maxLength": 500
},
"etaSeconds": {
"type": "integer",
"nullable": true,
"minimum": 0
},
"connections": {
"type": "integer",
"minimum": 0
},
"infoHash": {
"type": "string",
"maxLength": 120
},
"torrentName": {
"type": "string",
"maxLength": 255
},
"seeders": {
"type": "integer",
"minimum": 0
},
"leechers": {
"type": "integer",
"minimum": 0
},
"peers": {
"type": "integer",
"minimum": 0
},
"uploadedBytes": {
"type": "integer",
"minimum": 0
},
"trackers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"url": {
"type": "string",
"maxLength": 1024
},
"status": {
"type": "string",
"maxLength": 80
},
"peers": {
"type": "integer",
"minimum": 0
},
"seeds": {
"type": "integer",
"minimum": 0
},
"leechers": {
"type": "integer",
"minimum": 0
},
"message": {
"type": "string",
"maxLength": 500
}
},
"required": [
"url"
]
},
"maxItems": 20
},
"peerSamples": {
"type": "array",
"items": {
"type": "object",
"properties": {
"address": {
"type": "string",
"maxLength": 160
},
"client": {
"type": "string",
"maxLength": 160
},
"progress": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"downloadBps": {
"type": "integer",
"minimum": 0
},
"uploadBps": {
"type": "integer",
"minimum": 0
}
},
"required": [
"address"
]
},
"maxItems": 20
},
"files": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {
"type": "string",
"maxLength": 1024
},
"size": {
"type": "integer",
"minimum": 0
},
"completedBytes": {
"type": "integer",
"minimum": 0
},
"selected": {
"type": "boolean"
}
},
"required": [
"path",
"size"
]
},
"maxItems": 50
}
}
},
"uploadToken": {
"type": "string"
},
@@ -492,10 +648,166 @@
"type": "integer",
"format": "int64"
},
"errorMessage": {
"type": "string",
"nullable": true
},
"resultObjectId": {
"type": "string",
"nullable": true
},
"detail": {
"type": "object",
"nullable": true,
"properties": {
"engine": {
"type": "string",
"enum": [
"builtin",
"aria2",
"qbittorrent"
]
},
"phase": {
"type": "string",
"maxLength": 80
},
"message": {
"type": "string",
"maxLength": 500
},
"etaSeconds": {
"type": "integer",
"nullable": true,
"minimum": 0
},
"connections": {
"type": "integer",
"minimum": 0
},
"infoHash": {
"type": "string",
"maxLength": 120
},
"torrentName": {
"type": "string",
"maxLength": 255
},
"seeders": {
"type": "integer",
"minimum": 0
},
"leechers": {
"type": "integer",
"minimum": 0
},
"peers": {
"type": "integer",
"minimum": 0
},
"uploadedBytes": {
"type": "integer",
"minimum": 0
},
"trackers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"url": {
"type": "string",
"maxLength": 1024
},
"status": {
"type": "string",
"maxLength": 80
},
"peers": {
"type": "integer",
"minimum": 0
},
"seeds": {
"type": "integer",
"minimum": 0
},
"leechers": {
"type": "integer",
"minimum": 0
},
"message": {
"type": "string",
"maxLength": 500
}
},
"required": [
"url"
]
},
"maxItems": 20
},
"peerSamples": {
"type": "array",
"items": {
"type": "object",
"properties": {
"address": {
"type": "string",
"maxLength": 160
},
"client": {
"type": "string",
"maxLength": 160
},
"progress": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"downloadBps": {
"type": "integer",
"minimum": 0
},
"uploadBps": {
"type": "integer",
"minimum": 0
}
},
"required": [
"address"
]
},
"maxItems": 20
},
"files": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {
"type": "string",
"maxLength": 1024
},
"size": {
"type": "integer",
"minimum": 0
},
"completedBytes": {
"type": "integer",
"minimum": 0
},
"selected": {
"type": "boolean"
}
},
"required": [
"path",
"size"
]
},
"maxItems": 50
}
}
},
"uploadToken": {
"type": "string"
},
@@ -647,10 +959,166 @@
"type": "integer",
"format": "int64"
},
"errorMessage": {
"type": "string",
"nullable": true
},
"resultObjectId": {
"type": "string",
"nullable": true
},
"detail": {
"type": "object",
"nullable": true,
"properties": {
"engine": {
"type": "string",
"enum": [
"builtin",
"aria2",
"qbittorrent"
]
},
"phase": {
"type": "string",
"maxLength": 80
},
"message": {
"type": "string",
"maxLength": 500
},
"etaSeconds": {
"type": "integer",
"nullable": true,
"minimum": 0
},
"connections": {
"type": "integer",
"minimum": 0
},
"infoHash": {
"type": "string",
"maxLength": 120
},
"torrentName": {
"type": "string",
"maxLength": 255
},
"seeders": {
"type": "integer",
"minimum": 0
},
"leechers": {
"type": "integer",
"minimum": 0
},
"peers": {
"type": "integer",
"minimum": 0
},
"uploadedBytes": {
"type": "integer",
"minimum": 0
},
"trackers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"url": {
"type": "string",
"maxLength": 1024
},
"status": {
"type": "string",
"maxLength": 80
},
"peers": {
"type": "integer",
"minimum": 0
},
"seeds": {
"type": "integer",
"minimum": 0
},
"leechers": {
"type": "integer",
"minimum": 0
},
"message": {
"type": "string",
"maxLength": 500
}
},
"required": [
"url"
]
},
"maxItems": 20
},
"peerSamples": {
"type": "array",
"items": {
"type": "object",
"properties": {
"address": {
"type": "string",
"maxLength": 160
},
"client": {
"type": "string",
"maxLength": 160
},
"progress": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"downloadBps": {
"type": "integer",
"minimum": 0
},
"uploadBps": {
"type": "integer",
"minimum": 0
}
},
"required": [
"address"
]
},
"maxItems": 20
},
"files": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {
"type": "string",
"maxLength": 1024
},
"size": {
"type": "integer",
"minimum": 0
},
"completedBytes": {
"type": "integer",
"minimum": 0
},
"selected": {
"type": "boolean"
}
},
"required": [
"path",
"size"
]
},
"maxItems": 50
}
}
},
"uploadToken": {
"type": "string"
},
@@ -752,6 +1220,158 @@
"type": "string",
"nullable": true,
"minLength": 1
},
"detail": {
"type": "object",
"nullable": true,
"properties": {
"engine": {
"type": "string",
"enum": [
"builtin",
"aria2",
"qbittorrent"
]
},
"phase": {
"type": "string",
"maxLength": 80
},
"message": {
"type": "string",
"maxLength": 500
},
"etaSeconds": {
"type": "integer",
"nullable": true,
"minimum": 0
},
"connections": {
"type": "integer",
"minimum": 0
},
"infoHash": {
"type": "string",
"maxLength": 120
},
"torrentName": {
"type": "string",
"maxLength": 255
},
"seeders": {
"type": "integer",
"minimum": 0
},
"leechers": {
"type": "integer",
"minimum": 0
},
"peers": {
"type": "integer",
"minimum": 0
},
"uploadedBytes": {
"type": "integer",
"minimum": 0
},
"trackers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"url": {
"type": "string",
"maxLength": 1024
},
"status": {
"type": "string",
"maxLength": 80
},
"peers": {
"type": "integer",
"minimum": 0
},
"seeds": {
"type": "integer",
"minimum": 0
},
"leechers": {
"type": "integer",
"minimum": 0
},
"message": {
"type": "string",
"maxLength": 500
}
},
"required": [
"url"
]
},
"maxItems": 20
},
"peerSamples": {
"type": "array",
"items": {
"type": "object",
"properties": {
"address": {
"type": "string",
"maxLength": 160
},
"client": {
"type": "string",
"maxLength": 160
},
"progress": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"downloadBps": {
"type": "integer",
"minimum": 0
},
"uploadBps": {
"type": "integer",
"minimum": 0
}
},
"required": [
"address"
]
},
"maxItems": 20
},
"files": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {
"type": "string",
"maxLength": 1024
},
"size": {
"type": "integer",
"minimum": 0
},
"completedBytes": {
"type": "integer",
"minimum": 0
},
"selected": {
"type": "boolean"
}
},
"required": [
"path",
"size"
]
},
"maxItems": 50
}
}
}
}
}
@@ -816,10 +1436,166 @@
"type": "integer",
"format": "int64"
},
"errorMessage": {
"type": "string",
"nullable": true
},
"resultObjectId": {
"type": "string",
"nullable": true
},
"detail": {
"type": "object",
"nullable": true,
"properties": {
"engine": {
"type": "string",
"enum": [
"builtin",
"aria2",
"qbittorrent"
]
},
"phase": {
"type": "string",
"maxLength": 80
},
"message": {
"type": "string",
"maxLength": 500
},
"etaSeconds": {
"type": "integer",
"nullable": true,
"minimum": 0
},
"connections": {
"type": "integer",
"minimum": 0
},
"infoHash": {
"type": "string",
"maxLength": 120
},
"torrentName": {
"type": "string",
"maxLength": 255
},
"seeders": {
"type": "integer",
"minimum": 0
},
"leechers": {
"type": "integer",
"minimum": 0
},
"peers": {
"type": "integer",
"minimum": 0
},
"uploadedBytes": {
"type": "integer",
"minimum": 0
},
"trackers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"url": {
"type": "string",
"maxLength": 1024
},
"status": {
"type": "string",
"maxLength": 80
},
"peers": {
"type": "integer",
"minimum": 0
},
"seeds": {
"type": "integer",
"minimum": 0
},
"leechers": {
"type": "integer",
"minimum": 0
},
"message": {
"type": "string",
"maxLength": 500
}
},
"required": [
"url"
]
},
"maxItems": 20
},
"peerSamples": {
"type": "array",
"items": {
"type": "object",
"properties": {
"address": {
"type": "string",
"maxLength": 160
},
"client": {
"type": "string",
"maxLength": 160
},
"progress": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"downloadBps": {
"type": "integer",
"minimum": 0
},
"uploadBps": {
"type": "integer",
"minimum": 0
}
},
"required": [
"address"
]
},
"maxItems": 20
},
"files": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {
"type": "string",
"maxLength": 1024
},
"size": {
"type": "integer",
"minimum": 0
},
"completedBytes": {
"type": "integer",
"minimum": 0
},
"selected": {
"type": "boolean"
}
},
"required": [
"path",
"size"
]
},
"maxItems": 50
}
}
},
"uploadToken": {
"type": "string"
},
+1 -1
View File
@@ -5,6 +5,7 @@ go 1.25.0
require (
github.com/Braurbeki/arigo v0.0.7
github.com/autobrr/go-qbittorrent v1.16.0
github.com/oapi-codegen/runtime v1.4.1
github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.21.0
)
@@ -20,7 +21,6 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.4.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/oapi-codegen/runtime v1.4.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
+2
View File
@@ -39,6 +39,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/oapi-codegen/nullable v1.1.0 h1:eAh8JVc5430VtYVnq00Hrbpag9PFRGWLjxR1/3KntMs=
github.com/oapi-codegen/nullable v1.1.0/go.mod h1:KUZ3vUzkmEKY90ksAmit2+5juDIhIZhfDl+0PwOQlFY=
github.com/oapi-codegen/runtime v1.4.1 h1:9nwLoI+KrWxzbBcp0jO/R8uXqbik/HUyCvPeU68Y/qo=
github.com/oapi-codegen/runtime v1.4.1/go.mod h1:GwV7hC2hviaMzj+ITfHVRESK5J2W/GefVwIND/bMGvU=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+63 -20
View File
@@ -26,19 +26,61 @@ type Page[T any] struct {
}
type DownloadTask struct {
ID string `json:"id"`
SourceType string `json:"sourceType"`
SourceURI string `json:"sourceUri"`
Name string `json:"name"`
TargetFolder string `json:"targetFolder"`
Status string `json:"status"`
DownloadedBytes int64 `json:"downloadedBytes"`
TotalBytes *int64 `json:"totalBytes"`
DownloadBps int64 `json:"downloadBps"`
UploadBps int64 `json:"uploadBps"`
ResultObjectID string `json:"resultObjectId"`
UploadToken string `json:"uploadToken"`
AssignedDownloaderID string `json:"assignedDownloaderId"`
ID string `json:"id"`
SourceType string `json:"sourceType"`
SourceURI string `json:"sourceUri"`
Name string `json:"name"`
TargetFolder string `json:"targetFolder"`
Status string `json:"status"`
DownloadedBytes int64 `json:"downloadedBytes"`
TotalBytes *int64 `json:"totalBytes"`
DownloadBps int64 `json:"downloadBps"`
UploadBps int64 `json:"uploadBps"`
Detail *DownloadTaskDetail `json:"detail"`
ResultObjectID string `json:"resultObjectId"`
UploadToken string `json:"uploadToken"`
AssignedDownloaderID string `json:"assignedDownloaderId"`
}
type DownloadTaskDetail struct {
Engine string `json:"engine,omitempty"`
Phase string `json:"phase,omitempty"`
Message string `json:"message,omitempty"`
ETASeconds *int64 `json:"etaSeconds,omitempty"`
Connections *int64 `json:"connections,omitempty"`
InfoHash string `json:"infoHash,omitempty"`
TorrentName string `json:"torrentName,omitempty"`
Seeders *int64 `json:"seeders,omitempty"`
Leechers *int64 `json:"leechers,omitempty"`
Peers *int64 `json:"peers,omitempty"`
UploadedBytes *int64 `json:"uploadedBytes,omitempty"`
Trackers []DownloadTaskTracker `json:"trackers,omitempty"`
PeerSamples []DownloadTaskPeer `json:"peerSamples,omitempty"`
Files []DownloadTaskFile `json:"files,omitempty"`
}
type DownloadTaskTracker struct {
URL string `json:"url"`
Status string `json:"status,omitempty"`
Peers *int64 `json:"peers,omitempty"`
Seeds *int64 `json:"seeds,omitempty"`
Leechers *int64 `json:"leechers,omitempty"`
Message string `json:"message,omitempty"`
}
type DownloadTaskPeer struct {
Address string `json:"address"`
Client string `json:"client,omitempty"`
Progress *float64 `json:"progress,omitempty"`
DownloadBps *int64 `json:"downloadBps,omitempty"`
UploadBps *int64 `json:"uploadBps,omitempty"`
}
type DownloadTaskFile struct {
Path string `json:"path"`
Size int64 `json:"size"`
CompletedBytes *int64 `json:"completedBytes,omitempty"`
Selected *bool `json:"selected,omitempty"`
}
type Heartbeat struct {
@@ -56,13 +98,14 @@ type Heartbeat struct {
}
type TaskPatch struct {
Status string `json:"status,omitempty"`
DownloadedBytes *int64 `json:"downloadedBytes,omitempty"`
TotalBytes *int64 `json:"totalBytes,omitempty"`
DownloadBps *int64 `json:"downloadBps,omitempty"`
UploadBps *int64 `json:"uploadBps,omitempty"`
ErrorMessage *string `json:"errorMessage,omitempty"`
ResultObjectID *string `json:"resultObjectId,omitempty"`
Status string `json:"status,omitempty"`
DownloadedBytes *int64 `json:"downloadedBytes,omitempty"`
TotalBytes *int64 `json:"totalBytes,omitempty"`
DownloadBps *int64 `json:"downloadBps,omitempty"`
UploadBps *int64 `json:"uploadBps,omitempty"`
ErrorMessage *string `json:"errorMessage,omitempty"`
ResultObjectID *string `json:"resultObjectId,omitempty"`
Detail *DownloadTaskDetail `json:"detail,omitempty"`
}
type ObjectDraft struct {
+155 -6
View File
@@ -24,7 +24,7 @@ type Result struct {
Size int64
}
type Progress func(downloaded int64, total *int64, bps int64) error
type Progress func(downloaded int64, total *int64, bps int64, detail *client.DownloadTaskDetail) error
type Engine interface {
Check(ctx context.Context) error
@@ -88,7 +88,7 @@ func (h HTTP) Download(ctx context.Context, task client.DownloadTask, progress P
if _, err := io.Copy(file, io.TeeReader(res.Body, counter)); err != nil {
return Result{}, err
}
if err := progress(counter.downloaded, total, 0); err != nil {
if err := progress(counter.downloaded, total, 0, &client.DownloadTaskDetail{Engine: "builtin", Phase: "completed"}); err != nil {
return Result{}, err
}
return Result{Path: path, Name: name, Size: counter.downloaded}, nil
@@ -146,7 +146,7 @@ func (a Aria2) Download(ctx context.Context, task client.DownloadTask, progress
}
initialProgress := progress
if task.SourceType != "http" {
initialProgress = func(downloaded int64, total *int64, bps int64) error { return nil }
initialProgress = func(downloaded int64, total *int64, bps int64, detail *client.DownloadTaskDetail) error { return nil }
}
status, err := a.waitAria2(ctx, &aria, gid.GID, initialProgress)
if err != nil {
@@ -273,7 +273,7 @@ func (p *progressWriter) Write(data []byte) (int, error) {
now := time.Now()
if now.Sub(p.lastAt) >= time.Second {
bps := int64(float64(p.downloaded-p.lastBytes) / now.Sub(p.lastAt).Seconds())
if err := p.progress(p.downloaded, p.total, bps); err != nil {
if err := p.progress(p.downloaded, p.total, bps, &client.DownloadTaskDetail{Engine: "builtin", Phase: "downloading"}); err != nil {
return n, err
}
p.lastBytes = p.downloaded
@@ -307,7 +307,7 @@ func (a Aria2) waitAria2(ctx context.Context, aria **arigo.Client, gid string, p
if total > 0 {
totalPtr = &total
}
if err := progress(completed, totalPtr, bps); err != nil {
if err := progress(completed, totalPtr, bps, aria2Detail(status)); err != nil {
_ = (*aria).ForcePause(gid)
return arigo.Status{}, err
}
@@ -359,6 +359,70 @@ func isAria2RPCDisconnected(err error) bool {
return errors.Is(err, rpc2.ErrShutdown) || errors.Is(err, io.ErrClosedPipe) || strings.Contains(err.Error(), "connection is shut down")
}
func aria2Detail(status arigo.Status) *client.DownloadTaskDetail {
connections := int64(status.Connections)
seeders := int64(status.NumSeeders)
uploaded := int64(status.UploadLength)
detail := &client.DownloadTaskDetail{
Engine: "aria2",
Phase: string(status.Status),
Connections: &connections,
InfoHash: status.InfoHash,
TorrentName: status.BitTorrent.Info.Name,
Seeders: &seeders,
UploadedBytes: &uploaded,
Trackers: aria2Trackers(status.BitTorrent.AnnounceList),
Files: aria2Files(status.Files),
}
if status.ErrorMessage != "" {
detail.Message = status.ErrorMessage
}
return detail
}
func aria2Trackers(announceList [][]string) []client.DownloadTaskTracker {
trackers := make([]client.DownloadTaskTracker, 0, 20)
seen := map[string]struct{}{}
for _, tier := range announceList {
for _, url := range tier {
if url == "" {
continue
}
if _, exists := seen[url]; exists {
continue
}
seen[url] = struct{}{}
trackers = append(trackers, client.DownloadTaskTracker{URL: url})
if len(trackers) >= 20 {
return trackers
}
}
}
return trackers
}
func aria2Files(files []arigo.File) []client.DownloadTaskFile {
out := make([]client.DownloadTaskFile, 0, min(len(files), 50))
for _, file := range files {
if file.Path == "" || isAria2MetadataPath(file.Path) {
continue
}
size := int64(file.Length)
completed := int64(file.CompletedLength)
selected := file.Selected
out = append(out, client.DownloadTaskFile{
Path: file.Path,
Size: size,
CompletedBytes: &completed,
Selected: &selected,
})
if len(out) >= 50 {
break
}
}
return out
}
func hasAria2LocalFile(files []arigo.File) bool {
for _, file := range files {
if file.Path != "" && !isAria2MetadataPath(file.Path) {
@@ -397,7 +461,7 @@ func waitQBittorrent(
if total > 0 {
totalPtr = &total
}
if err := progress(torrent.Completed, totalPtr, torrent.DlSpeed); err != nil {
if err := progress(torrent.Completed, totalPtr, torrent.DlSpeed, qbittorrentDetail(ctx, qbt, torrent)); err != nil {
_ = qbt.StopCtx(ctx, []string{torrent.Hash})
return qbittorrent.Torrent{}, err
}
@@ -411,6 +475,91 @@ func waitQBittorrent(
}
}
func qbittorrentDetail(ctx context.Context, qbt *qbittorrent.Client, torrent qbittorrent.Torrent) *client.DownloadTaskDetail {
connections := int64(torrent.NumSeeds + torrent.NumLeechs)
seeders := torrent.NumSeeds
leechers := torrent.NumLeechs
peers := torrent.NumComplete + torrent.NumIncomplete
uploaded := torrent.Uploaded
var eta *int64
if torrent.ETA >= 0 {
eta = &torrent.ETA
}
return &client.DownloadTaskDetail{
Engine: "qbittorrent",
Phase: string(torrent.State),
ETASeconds: eta,
Connections: &connections,
InfoHash: torrent.Hash,
TorrentName: torrent.Name,
Seeders: &seeders,
Leechers: &leechers,
Peers: &peers,
UploadedBytes: &uploaded,
Trackers: qbittorrentTrackers(ctx, qbt, torrent),
PeerSamples: qbittorrentPeers(ctx, qbt, torrent.Hash),
}
}
func qbittorrentTrackers(ctx context.Context, qbt *qbittorrent.Client, torrent qbittorrent.Torrent) []client.DownloadTaskTracker {
trackers := torrent.Trackers
if len(trackers) == 0 && torrent.Hash != "" {
loaded, err := qbt.GetTorrentTrackersCtx(ctx, torrent.Hash)
if err == nil {
trackers = loaded
}
}
out := make([]client.DownloadTaskTracker, 0, min(len(trackers), 20))
for _, tracker := range trackers {
peers := int64(tracker.NumPeers)
seeds := int64(tracker.NumSeeds)
leechers := int64(tracker.NumLeechers)
out = append(out, client.DownloadTaskTracker{
URL: tracker.Url,
Status: fmt.Sprint(tracker.Status),
Peers: &peers,
Seeds: &seeds,
Leechers: &leechers,
Message: tracker.Message,
})
if len(out) >= 20 {
break
}
}
return out
}
func qbittorrentPeers(ctx context.Context, qbt *qbittorrent.Client, hash string) []client.DownloadTaskPeer {
if hash == "" {
return nil
}
peers, err := qbt.GetTorrentPeersCtx(ctx, hash, 0)
if err != nil || peers == nil {
return nil
}
out := make([]client.DownloadTaskPeer, 0, min(len(peers.Peers), 20))
for address, peer := range peers.Peers {
progress := peer.Progress
down := peer.DownSpeed
up := peer.UpSpeed
label := address
if peer.IP != "" && peer.Port > 0 {
label = fmt.Sprintf("%s:%d", peer.IP, peer.Port)
}
out = append(out, client.DownloadTaskPeer{
Address: label,
Client: peer.Client,
Progress: &progress,
DownloadBps: &down,
UploadBps: &up,
})
if len(out) >= 20 {
break
}
}
return out
}
func resultFromAria2Files(task client.DownloadTask, taskDir string, files []arigo.File) (Result, error) {
paths := make([]string, 0, len(files))
for _, file := range files {
+8 -3
View File
@@ -23,11 +23,13 @@ func TestHTTPDownload(t *testing.T) {
dir := t.TempDir()
progressCalls := 0
var lastDetail *client.DownloadTaskDetail
result, err := (HTTP{Dir: dir}).Download(
context.Background(),
client.DownloadTask{ID: "task-1", SourceType: "http", SourceURI: server.URL + "/file.txt"},
func(downloaded int64, total *int64, bps int64) error {
func(downloaded int64, total *int64, bps int64, detail *client.DownloadTaskDetail) error {
progressCalls++
lastDetail = detail
if downloaded < 0 {
t.Fatalf("downloaded bytes must not be negative")
}
@@ -53,13 +55,16 @@ func TestHTTPDownload(t *testing.T) {
if progressCalls == 0 {
t.Fatal("expected progress callback")
}
if lastDetail == nil || lastDetail.Engine != "builtin" {
t.Fatalf("expected builtin progress detail, got %#v", lastDetail)
}
}
func TestHTTPRejectsMagnet(t *testing.T) {
_, err := (HTTP{Dir: t.TempDir()}).Download(
context.Background(),
client.DownloadTask{ID: "task-1", SourceType: "magnet", SourceURI: "magnet:?xt=urn:btih:abc"},
func(downloaded int64, total *int64, bps int64) error { return nil },
func(downloaded int64, total *int64, bps int64, detail *client.DownloadTaskDetail) error { return nil },
)
if err == nil {
t.Fatal("expected magnet to be rejected by HTTP engine")
@@ -90,7 +95,7 @@ func TestQBittorrentHTTPDelegatesToBuiltin(t *testing.T) {
result, err := (QBittorrent{URL: "http://127.0.0.1:1", Dir: t.TempDir()}).Download(
context.Background(),
client.DownloadTask{ID: "task-1", SourceType: "http", SourceURI: server.URL + "/file.txt"},
func(downloaded int64, total *int64, bps int64) error { return nil },
func(downloaded int64, total *int64, bps int64, detail *client.DownloadTaskDetail) error { return nil },
)
if err != nil {
t.Fatal(err)
@@ -246,7 +246,7 @@ func TestLiveQBittorrentDownloadTorrentURL(t *testing.T) {
func runLiveDownload(t *testing.T, ctx context.Context, downloader Engine, task client.DownloadTask) Result {
t.Helper()
var lastDownloaded int64
result, err := downloader.Download(ctx, task, func(downloaded int64, total *int64, bps int64) error {
result, err := downloader.Download(ctx, task, func(downloaded int64, total *int64, bps int64, detail *client.DownloadTaskDetail) error {
if downloaded < lastDownloaded {
t.Fatalf("download progress moved backwards: %d < %d", downloaded, lastDownloaded)
}
+544 -110
View File
@@ -229,6 +229,27 @@ func (e GetApiDownloadTasksParamsAssignedTo) Valid() bool {
}
}
// Defines values for GetApiDownloadTasks200JSONResponseBodyItemsDetailEngine.
const (
GetApiDownloadTasks200JSONResponseBodyItemsDetailEngineAria2 GetApiDownloadTasks200JSONResponseBodyItemsDetailEngine = "aria2"
GetApiDownloadTasks200JSONResponseBodyItemsDetailEngineBuiltin GetApiDownloadTasks200JSONResponseBodyItemsDetailEngine = "builtin"
GetApiDownloadTasks200JSONResponseBodyItemsDetailEngineQbittorrent GetApiDownloadTasks200JSONResponseBodyItemsDetailEngine = "qbittorrent"
)
// Valid indicates whether the value is a known member of the GetApiDownloadTasks200JSONResponseBodyItemsDetailEngine enum.
func (e GetApiDownloadTasks200JSONResponseBodyItemsDetailEngine) Valid() bool {
switch e {
case GetApiDownloadTasks200JSONResponseBodyItemsDetailEngineAria2:
return true
case GetApiDownloadTasks200JSONResponseBodyItemsDetailEngineBuiltin:
return true
case GetApiDownloadTasks200JSONResponseBodyItemsDetailEngineQbittorrent:
return true
default:
return false
}
}
// Defines values for GetApiDownloadTasks200JSONResponseBodyItemsSourceType.
const (
GetApiDownloadTasks200JSONResponseBodyItemsSourceTypeHttp GetApiDownloadTasks200JSONResponseBodyItemsSourceType = "http"
@@ -307,6 +328,27 @@ func (e PostApiDownloadTasksJSONBodySourceType) Valid() bool {
}
}
// Defines values for PostApiDownloadTasks201JSONResponseBodyDetailEngine.
const (
PostApiDownloadTasks201JSONResponseBodyDetailEngineAria2 PostApiDownloadTasks201JSONResponseBodyDetailEngine = "aria2"
PostApiDownloadTasks201JSONResponseBodyDetailEngineBuiltin PostApiDownloadTasks201JSONResponseBodyDetailEngine = "builtin"
PostApiDownloadTasks201JSONResponseBodyDetailEngineQbittorrent PostApiDownloadTasks201JSONResponseBodyDetailEngine = "qbittorrent"
)
// Valid indicates whether the value is a known member of the PostApiDownloadTasks201JSONResponseBodyDetailEngine enum.
func (e PostApiDownloadTasks201JSONResponseBodyDetailEngine) Valid() bool {
switch e {
case PostApiDownloadTasks201JSONResponseBodyDetailEngineAria2:
return true
case PostApiDownloadTasks201JSONResponseBodyDetailEngineBuiltin:
return true
case PostApiDownloadTasks201JSONResponseBodyDetailEngineQbittorrent:
return true
default:
return false
}
}
// Defines values for PostApiDownloadTasks201JSONResponseBodySourceType.
const (
PostApiDownloadTasks201JSONResponseBodySourceTypeHttp PostApiDownloadTasks201JSONResponseBodySourceType = "http"
@@ -364,6 +406,27 @@ func (e PostApiDownloadTasks201JSONResponseBodyStatus) Valid() bool {
}
}
// Defines values for GetApiDownloadTasksId200JSONResponseBodyDetailEngine.
const (
GetApiDownloadTasksId200JSONResponseBodyDetailEngineAria2 GetApiDownloadTasksId200JSONResponseBodyDetailEngine = "aria2"
GetApiDownloadTasksId200JSONResponseBodyDetailEngineBuiltin GetApiDownloadTasksId200JSONResponseBodyDetailEngine = "builtin"
GetApiDownloadTasksId200JSONResponseBodyDetailEngineQbittorrent GetApiDownloadTasksId200JSONResponseBodyDetailEngine = "qbittorrent"
)
// Valid indicates whether the value is a known member of the GetApiDownloadTasksId200JSONResponseBodyDetailEngine enum.
func (e GetApiDownloadTasksId200JSONResponseBodyDetailEngine) Valid() bool {
switch e {
case GetApiDownloadTasksId200JSONResponseBodyDetailEngineAria2:
return true
case GetApiDownloadTasksId200JSONResponseBodyDetailEngineBuiltin:
return true
case GetApiDownloadTasksId200JSONResponseBodyDetailEngineQbittorrent:
return true
default:
return false
}
}
// Defines values for GetApiDownloadTasksId200JSONResponseBodySourceType.
const (
GetApiDownloadTasksId200JSONResponseBodySourceTypeHttp GetApiDownloadTasksId200JSONResponseBodySourceType = "http"
@@ -421,6 +484,27 @@ func (e GetApiDownloadTasksId200JSONResponseBodyStatus) Valid() bool {
}
}
// Defines values for PatchApiDownloadTasksIdJSONBodyDetailEngine.
const (
PatchApiDownloadTasksIdJSONBodyDetailEngineAria2 PatchApiDownloadTasksIdJSONBodyDetailEngine = "aria2"
PatchApiDownloadTasksIdJSONBodyDetailEngineBuiltin PatchApiDownloadTasksIdJSONBodyDetailEngine = "builtin"
PatchApiDownloadTasksIdJSONBodyDetailEngineQbittorrent PatchApiDownloadTasksIdJSONBodyDetailEngine = "qbittorrent"
)
// Valid indicates whether the value is a known member of the PatchApiDownloadTasksIdJSONBodyDetailEngine enum.
func (e PatchApiDownloadTasksIdJSONBodyDetailEngine) Valid() bool {
switch e {
case PatchApiDownloadTasksIdJSONBodyDetailEngineAria2:
return true
case PatchApiDownloadTasksIdJSONBodyDetailEngineBuiltin:
return true
case PatchApiDownloadTasksIdJSONBodyDetailEngineQbittorrent:
return true
default:
return false
}
}
// Defines values for PatchApiDownloadTasksIdJSONBodyStatus.
const (
PatchApiDownloadTasksIdJSONBodyStatusAssigned PatchApiDownloadTasksIdJSONBodyStatus = "assigned"
@@ -457,6 +541,27 @@ func (e PatchApiDownloadTasksIdJSONBodyStatus) Valid() bool {
}
}
// Defines values for PatchApiDownloadTasksId200JSONResponseBodyDetailEngine.
const (
PatchApiDownloadTasksId200JSONResponseBodyDetailEngineAria2 PatchApiDownloadTasksId200JSONResponseBodyDetailEngine = "aria2"
PatchApiDownloadTasksId200JSONResponseBodyDetailEngineBuiltin PatchApiDownloadTasksId200JSONResponseBodyDetailEngine = "builtin"
PatchApiDownloadTasksId200JSONResponseBodyDetailEngineQbittorrent PatchApiDownloadTasksId200JSONResponseBodyDetailEngine = "qbittorrent"
)
// Valid indicates whether the value is a known member of the PatchApiDownloadTasksId200JSONResponseBodyDetailEngine enum.
func (e PatchApiDownloadTasksId200JSONResponseBodyDetailEngine) Valid() bool {
switch e {
case PatchApiDownloadTasksId200JSONResponseBodyDetailEngineAria2:
return true
case PatchApiDownloadTasksId200JSONResponseBodyDetailEngineBuiltin:
return true
case PatchApiDownloadTasksId200JSONResponseBodyDetailEngineQbittorrent:
return true
default:
return false
}
}
// Defines values for PatchApiDownloadTasksId200JSONResponseBodySourceType.
const (
Http PatchApiDownloadTasksId200JSONResponseBodySourceType = "http"
@@ -537,19 +642,19 @@ func (e PostApiDownloaderHeartbeatJSONBodyEngine) Valid() bool {
// Defines values for PostApiDownloaderHeartbeat200JSONResponseBodyHeartbeatEngine.
const (
PostApiDownloaderHeartbeat200JSONResponseBodyHeartbeatEngineAria2 PostApiDownloaderHeartbeat200JSONResponseBodyHeartbeatEngine = "aria2"
PostApiDownloaderHeartbeat200JSONResponseBodyHeartbeatEngineBuiltin PostApiDownloaderHeartbeat200JSONResponseBodyHeartbeatEngine = "builtin"
PostApiDownloaderHeartbeat200JSONResponseBodyHeartbeatEngineQbittorrent PostApiDownloaderHeartbeat200JSONResponseBodyHeartbeatEngine = "qbittorrent"
Aria2 PostApiDownloaderHeartbeat200JSONResponseBodyHeartbeatEngine = "aria2"
Builtin PostApiDownloaderHeartbeat200JSONResponseBodyHeartbeatEngine = "builtin"
Qbittorrent PostApiDownloaderHeartbeat200JSONResponseBodyHeartbeatEngine = "qbittorrent"
)
// Valid indicates whether the value is a known member of the PostApiDownloaderHeartbeat200JSONResponseBodyHeartbeatEngine enum.
func (e PostApiDownloaderHeartbeat200JSONResponseBodyHeartbeatEngine) Valid() bool {
switch e {
case PostApiDownloaderHeartbeat200JSONResponseBodyHeartbeatEngineAria2:
case Aria2:
return true
case PostApiDownloaderHeartbeat200JSONResponseBodyHeartbeatEngineBuiltin:
case Builtin:
return true
case PostApiDownloaderHeartbeat200JSONResponseBodyHeartbeatEngineQbittorrent:
case Qbittorrent:
return true
default:
return false
@@ -711,6 +816,9 @@ type GetApiDownloadTasksParamsStatus string
// GetApiDownloadTasksParamsAssignedTo defines parameters for GetApiDownloadTasks.
type GetApiDownloadTasksParamsAssignedTo string
// GetApiDownloadTasks200JSONResponseBodyItemsDetailEngine defines parameters for GetApiDownloadTasks.
type GetApiDownloadTasks200JSONResponseBodyItemsDetailEngine string
// GetApiDownloadTasks200JSONResponseBodyItemsSourceType defines parameters for GetApiDownloadTasks.
type GetApiDownloadTasks200JSONResponseBodyItemsSourceType string
@@ -730,12 +838,18 @@ type PostApiDownloadTasksJSONBody struct {
// PostApiDownloadTasksJSONBodySourceType defines parameters for PostApiDownloadTasks.
type PostApiDownloadTasksJSONBodySourceType string
// PostApiDownloadTasks201JSONResponseBodyDetailEngine defines parameters for PostApiDownloadTasks.
type PostApiDownloadTasks201JSONResponseBodyDetailEngine string
// PostApiDownloadTasks201JSONResponseBodySourceType defines parameters for PostApiDownloadTasks.
type PostApiDownloadTasks201JSONResponseBodySourceType string
// PostApiDownloadTasks201JSONResponseBodyStatus defines parameters for PostApiDownloadTasks.
type PostApiDownloadTasks201JSONResponseBodyStatus string
// GetApiDownloadTasksId200JSONResponseBodyDetailEngine defines parameters for GetApiDownloadTasksId.
type GetApiDownloadTasksId200JSONResponseBodyDetailEngine string
// GetApiDownloadTasksId200JSONResponseBodySourceType defines parameters for GetApiDownloadTasksId.
type GetApiDownloadTasksId200JSONResponseBodySourceType string
@@ -744,6 +858,40 @@ type GetApiDownloadTasksId200JSONResponseBodyStatus string
// PatchApiDownloadTasksIdJSONBody defines parameters for PatchApiDownloadTasksId.
type PatchApiDownloadTasksIdJSONBody struct {
Detail *struct {
Connections *int `json:"connections,omitempty"`
Engine *PatchApiDownloadTasksIdJSONBodyDetailEngine `json:"engine,omitempty"`
EtaSeconds *int `json:"etaSeconds,omitempty"`
Files *[]struct {
CompletedBytes *int `json:"completedBytes,omitempty"`
Path string `json:"path"`
Selected *bool `json:"selected,omitempty"`
Size int `json:"size"`
} `json:"files,omitempty"`
InfoHash *string `json:"infoHash,omitempty"`
Leechers *int `json:"leechers,omitempty"`
Message *string `json:"message,omitempty"`
PeerSamples *[]struct {
Address string `json:"address"`
Client *string `json:"client,omitempty"`
DownloadBps *int `json:"downloadBps,omitempty"`
Progress *float32 `json:"progress,omitempty"`
UploadBps *int `json:"uploadBps,omitempty"`
} `json:"peerSamples,omitempty"`
Peers *int `json:"peers,omitempty"`
Phase *string `json:"phase,omitempty"`
Seeders *int `json:"seeders,omitempty"`
TorrentName *string `json:"torrentName,omitempty"`
Trackers *[]struct {
Leechers *int `json:"leechers,omitempty"`
Message *string `json:"message,omitempty"`
Peers *int `json:"peers,omitempty"`
Seeds *int `json:"seeds,omitempty"`
Status *string `json:"status,omitempty"`
Url string `json:"url"`
} `json:"trackers,omitempty"`
UploadedBytes *int `json:"uploadedBytes,omitempty"`
} `json:"detail,omitempty"`
DownloadBps *int `json:"downloadBps,omitempty"`
DownloadedBytes *int `json:"downloadedBytes,omitempty"`
ErrorMessage *string `json:"errorMessage,omitempty"`
@@ -753,9 +901,15 @@ type PatchApiDownloadTasksIdJSONBody struct {
UploadBps *int `json:"uploadBps,omitempty"`
}
// PatchApiDownloadTasksIdJSONBodyDetailEngine defines parameters for PatchApiDownloadTasksId.
type PatchApiDownloadTasksIdJSONBodyDetailEngine string
// PatchApiDownloadTasksIdJSONBodyStatus defines parameters for PatchApiDownloadTasksId.
type PatchApiDownloadTasksIdJSONBodyStatus string
// PatchApiDownloadTasksId200JSONResponseBodyDetailEngine defines parameters for PatchApiDownloadTasksId.
type PatchApiDownloadTasksId200JSONResponseBodyDetailEngine string
// PatchApiDownloadTasksId200JSONResponseBodySourceType defines parameters for PatchApiDownloadTasksId.
type PatchApiDownloadTasksId200JSONResponseBodySourceType string
@@ -2149,19 +2303,54 @@ type GetApiDownloadTasksResponse struct {
HTTPResponse *http.Response
JSON200 *struct {
Items []struct {
AssignedDownloaderId *string `json:"assignedDownloaderId,omitempty"`
DownloadBps int64 `json:"downloadBps"`
DownloadedBytes int64 `json:"downloadedBytes"`
Id string `json:"id"`
Name string `json:"name"`
ResultObjectId *string `json:"resultObjectId,omitempty"`
SourceType GetApiDownloadTasks200JSONResponseBodyItemsSourceType `json:"sourceType"`
SourceUri string `json:"sourceUri"`
Status GetApiDownloadTasks200JSONResponseBodyItemsStatus `json:"status"`
TargetFolder string `json:"targetFolder"`
TotalBytes *int64 `json:"totalBytes"`
UploadBps int64 `json:"uploadBps"`
UploadToken *string `json:"uploadToken,omitempty"`
AssignedDownloaderId *string `json:"assignedDownloaderId,omitempty"`
Detail *struct {
Connections *int `json:"connections,omitempty"`
Engine *GetApiDownloadTasks200JSONResponseBodyItemsDetailEngine `json:"engine,omitempty"`
EtaSeconds *int `json:"etaSeconds,omitempty"`
Files *[]struct {
CompletedBytes *int `json:"completedBytes,omitempty"`
Path string `json:"path"`
Selected *bool `json:"selected,omitempty"`
Size int `json:"size"`
} `json:"files,omitempty"`
InfoHash *string `json:"infoHash,omitempty"`
Leechers *int `json:"leechers,omitempty"`
Message *string `json:"message,omitempty"`
PeerSamples *[]struct {
Address string `json:"address"`
Client *string `json:"client,omitempty"`
DownloadBps *int `json:"downloadBps,omitempty"`
Progress *float32 `json:"progress,omitempty"`
UploadBps *int `json:"uploadBps,omitempty"`
} `json:"peerSamples,omitempty"`
Peers *int `json:"peers,omitempty"`
Phase *string `json:"phase,omitempty"`
Seeders *int `json:"seeders,omitempty"`
TorrentName *string `json:"torrentName,omitempty"`
Trackers *[]struct {
Leechers *int `json:"leechers,omitempty"`
Message *string `json:"message,omitempty"`
Peers *int `json:"peers,omitempty"`
Seeds *int `json:"seeds,omitempty"`
Status *string `json:"status,omitempty"`
Url string `json:"url"`
} `json:"trackers,omitempty"`
UploadedBytes *int `json:"uploadedBytes,omitempty"`
} `json:"detail,omitempty"`
DownloadBps int64 `json:"downloadBps"`
DownloadedBytes int64 `json:"downloadedBytes"`
ErrorMessage *string `json:"errorMessage,omitempty"`
Id string `json:"id"`
Name string `json:"name"`
ResultObjectId *string `json:"resultObjectId,omitempty"`
SourceType GetApiDownloadTasks200JSONResponseBodyItemsSourceType `json:"sourceType"`
SourceUri string `json:"sourceUri"`
Status GetApiDownloadTasks200JSONResponseBodyItemsStatus `json:"status"`
TargetFolder string `json:"targetFolder"`
TotalBytes *int64 `json:"totalBytes"`
UploadBps int64 `json:"uploadBps"`
UploadToken *string `json:"uploadToken,omitempty"`
} `json:"items"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
@@ -2200,19 +2389,54 @@ type PostApiDownloadTasksResponse struct {
Body []byte
HTTPResponse *http.Response
JSON201 *struct {
AssignedDownloaderId *string `json:"assignedDownloaderId,omitempty"`
DownloadBps int64 `json:"downloadBps"`
DownloadedBytes int64 `json:"downloadedBytes"`
Id string `json:"id"`
Name string `json:"name"`
ResultObjectId *string `json:"resultObjectId,omitempty"`
SourceType PostApiDownloadTasks201JSONResponseBodySourceType `json:"sourceType"`
SourceUri string `json:"sourceUri"`
Status PostApiDownloadTasks201JSONResponseBodyStatus `json:"status"`
TargetFolder string `json:"targetFolder"`
TotalBytes *int64 `json:"totalBytes"`
UploadBps int64 `json:"uploadBps"`
UploadToken *string `json:"uploadToken,omitempty"`
AssignedDownloaderId *string `json:"assignedDownloaderId,omitempty"`
Detail *struct {
Connections *int `json:"connections,omitempty"`
Engine *PostApiDownloadTasks201JSONResponseBodyDetailEngine `json:"engine,omitempty"`
EtaSeconds *int `json:"etaSeconds,omitempty"`
Files *[]struct {
CompletedBytes *int `json:"completedBytes,omitempty"`
Path string `json:"path"`
Selected *bool `json:"selected,omitempty"`
Size int `json:"size"`
} `json:"files,omitempty"`
InfoHash *string `json:"infoHash,omitempty"`
Leechers *int `json:"leechers,omitempty"`
Message *string `json:"message,omitempty"`
PeerSamples *[]struct {
Address string `json:"address"`
Client *string `json:"client,omitempty"`
DownloadBps *int `json:"downloadBps,omitempty"`
Progress *float32 `json:"progress,omitempty"`
UploadBps *int `json:"uploadBps,omitempty"`
} `json:"peerSamples,omitempty"`
Peers *int `json:"peers,omitempty"`
Phase *string `json:"phase,omitempty"`
Seeders *int `json:"seeders,omitempty"`
TorrentName *string `json:"torrentName,omitempty"`
Trackers *[]struct {
Leechers *int `json:"leechers,omitempty"`
Message *string `json:"message,omitempty"`
Peers *int `json:"peers,omitempty"`
Seeds *int `json:"seeds,omitempty"`
Status *string `json:"status,omitempty"`
Url string `json:"url"`
} `json:"trackers,omitempty"`
UploadedBytes *int `json:"uploadedBytes,omitempty"`
} `json:"detail,omitempty"`
DownloadBps int64 `json:"downloadBps"`
DownloadedBytes int64 `json:"downloadedBytes"`
ErrorMessage *string `json:"errorMessage,omitempty"`
Id string `json:"id"`
Name string `json:"name"`
ResultObjectId *string `json:"resultObjectId,omitempty"`
SourceType PostApiDownloadTasks201JSONResponseBodySourceType `json:"sourceType"`
SourceUri string `json:"sourceUri"`
Status PostApiDownloadTasks201JSONResponseBodyStatus `json:"status"`
TargetFolder string `json:"targetFolder"`
TotalBytes *int64 `json:"totalBytes"`
UploadBps int64 `json:"uploadBps"`
UploadToken *string `json:"uploadToken,omitempty"`
}
JSON401 *struct {
Error string `json:"error"`
@@ -2253,19 +2477,54 @@ type GetApiDownloadTasksIdResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *struct {
AssignedDownloaderId *string `json:"assignedDownloaderId,omitempty"`
DownloadBps int64 `json:"downloadBps"`
DownloadedBytes int64 `json:"downloadedBytes"`
Id string `json:"id"`
Name string `json:"name"`
ResultObjectId *string `json:"resultObjectId,omitempty"`
SourceType GetApiDownloadTasksId200JSONResponseBodySourceType `json:"sourceType"`
SourceUri string `json:"sourceUri"`
Status GetApiDownloadTasksId200JSONResponseBodyStatus `json:"status"`
TargetFolder string `json:"targetFolder"`
TotalBytes *int64 `json:"totalBytes"`
UploadBps int64 `json:"uploadBps"`
UploadToken *string `json:"uploadToken,omitempty"`
AssignedDownloaderId *string `json:"assignedDownloaderId,omitempty"`
Detail *struct {
Connections *int `json:"connections,omitempty"`
Engine *GetApiDownloadTasksId200JSONResponseBodyDetailEngine `json:"engine,omitempty"`
EtaSeconds *int `json:"etaSeconds,omitempty"`
Files *[]struct {
CompletedBytes *int `json:"completedBytes,omitempty"`
Path string `json:"path"`
Selected *bool `json:"selected,omitempty"`
Size int `json:"size"`
} `json:"files,omitempty"`
InfoHash *string `json:"infoHash,omitempty"`
Leechers *int `json:"leechers,omitempty"`
Message *string `json:"message,omitempty"`
PeerSamples *[]struct {
Address string `json:"address"`
Client *string `json:"client,omitempty"`
DownloadBps *int `json:"downloadBps,omitempty"`
Progress *float32 `json:"progress,omitempty"`
UploadBps *int `json:"uploadBps,omitempty"`
} `json:"peerSamples,omitempty"`
Peers *int `json:"peers,omitempty"`
Phase *string `json:"phase,omitempty"`
Seeders *int `json:"seeders,omitempty"`
TorrentName *string `json:"torrentName,omitempty"`
Trackers *[]struct {
Leechers *int `json:"leechers,omitempty"`
Message *string `json:"message,omitempty"`
Peers *int `json:"peers,omitempty"`
Seeds *int `json:"seeds,omitempty"`
Status *string `json:"status,omitempty"`
Url string `json:"url"`
} `json:"trackers,omitempty"`
UploadedBytes *int `json:"uploadedBytes,omitempty"`
} `json:"detail,omitempty"`
DownloadBps int64 `json:"downloadBps"`
DownloadedBytes int64 `json:"downloadedBytes"`
ErrorMessage *string `json:"errorMessage,omitempty"`
Id string `json:"id"`
Name string `json:"name"`
ResultObjectId *string `json:"resultObjectId,omitempty"`
SourceType GetApiDownloadTasksId200JSONResponseBodySourceType `json:"sourceType"`
SourceUri string `json:"sourceUri"`
Status GetApiDownloadTasksId200JSONResponseBodyStatus `json:"status"`
TargetFolder string `json:"targetFolder"`
TotalBytes *int64 `json:"totalBytes"`
UploadBps int64 `json:"uploadBps"`
UploadToken *string `json:"uploadToken,omitempty"`
}
JSON404 *struct {
Error string `json:"error"`
@@ -2300,19 +2559,54 @@ type PatchApiDownloadTasksIdResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *struct {
AssignedDownloaderId *string `json:"assignedDownloaderId,omitempty"`
DownloadBps int64 `json:"downloadBps"`
DownloadedBytes int64 `json:"downloadedBytes"`
Id string `json:"id"`
Name string `json:"name"`
ResultObjectId *string `json:"resultObjectId,omitempty"`
SourceType PatchApiDownloadTasksId200JSONResponseBodySourceType `json:"sourceType"`
SourceUri string `json:"sourceUri"`
Status PatchApiDownloadTasksId200JSONResponseBodyStatus `json:"status"`
TargetFolder string `json:"targetFolder"`
TotalBytes *int64 `json:"totalBytes"`
UploadBps int64 `json:"uploadBps"`
UploadToken *string `json:"uploadToken,omitempty"`
AssignedDownloaderId *string `json:"assignedDownloaderId,omitempty"`
Detail *struct {
Connections *int `json:"connections,omitempty"`
Engine *PatchApiDownloadTasksId200JSONResponseBodyDetailEngine `json:"engine,omitempty"`
EtaSeconds *int `json:"etaSeconds,omitempty"`
Files *[]struct {
CompletedBytes *int `json:"completedBytes,omitempty"`
Path string `json:"path"`
Selected *bool `json:"selected,omitempty"`
Size int `json:"size"`
} `json:"files,omitempty"`
InfoHash *string `json:"infoHash,omitempty"`
Leechers *int `json:"leechers,omitempty"`
Message *string `json:"message,omitempty"`
PeerSamples *[]struct {
Address string `json:"address"`
Client *string `json:"client,omitempty"`
DownloadBps *int `json:"downloadBps,omitempty"`
Progress *float32 `json:"progress,omitempty"`
UploadBps *int `json:"uploadBps,omitempty"`
} `json:"peerSamples,omitempty"`
Peers *int `json:"peers,omitempty"`
Phase *string `json:"phase,omitempty"`
Seeders *int `json:"seeders,omitempty"`
TorrentName *string `json:"torrentName,omitempty"`
Trackers *[]struct {
Leechers *int `json:"leechers,omitempty"`
Message *string `json:"message,omitempty"`
Peers *int `json:"peers,omitempty"`
Seeds *int `json:"seeds,omitempty"`
Status *string `json:"status,omitempty"`
Url string `json:"url"`
} `json:"trackers,omitempty"`
UploadedBytes *int `json:"uploadedBytes,omitempty"`
} `json:"detail,omitempty"`
DownloadBps int64 `json:"downloadBps"`
DownloadedBytes int64 `json:"downloadedBytes"`
ErrorMessage *string `json:"errorMessage,omitempty"`
Id string `json:"id"`
Name string `json:"name"`
ResultObjectId *string `json:"resultObjectId,omitempty"`
SourceType PatchApiDownloadTasksId200JSONResponseBodySourceType `json:"sourceType"`
SourceUri string `json:"sourceUri"`
Status PatchApiDownloadTasksId200JSONResponseBodyStatus `json:"status"`
TargetFolder string `json:"targetFolder"`
TotalBytes *int64 `json:"totalBytes"`
UploadBps int64 `json:"uploadBps"`
UploadToken *string `json:"uploadToken,omitempty"`
}
JSON401 *struct {
Error string `json:"error"`
@@ -2936,19 +3230,54 @@ func ParseGetApiDownloadTasksResponse(rsp *http.Response) (*GetApiDownloadTasksR
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest struct {
Items []struct {
AssignedDownloaderId *string `json:"assignedDownloaderId,omitempty"`
DownloadBps int64 `json:"downloadBps"`
DownloadedBytes int64 `json:"downloadedBytes"`
Id string `json:"id"`
Name string `json:"name"`
ResultObjectId *string `json:"resultObjectId,omitempty"`
SourceType GetApiDownloadTasks200JSONResponseBodyItemsSourceType `json:"sourceType"`
SourceUri string `json:"sourceUri"`
Status GetApiDownloadTasks200JSONResponseBodyItemsStatus `json:"status"`
TargetFolder string `json:"targetFolder"`
TotalBytes *int64 `json:"totalBytes"`
UploadBps int64 `json:"uploadBps"`
UploadToken *string `json:"uploadToken,omitempty"`
AssignedDownloaderId *string `json:"assignedDownloaderId,omitempty"`
Detail *struct {
Connections *int `json:"connections,omitempty"`
Engine *GetApiDownloadTasks200JSONResponseBodyItemsDetailEngine `json:"engine,omitempty"`
EtaSeconds *int `json:"etaSeconds,omitempty"`
Files *[]struct {
CompletedBytes *int `json:"completedBytes,omitempty"`
Path string `json:"path"`
Selected *bool `json:"selected,omitempty"`
Size int `json:"size"`
} `json:"files,omitempty"`
InfoHash *string `json:"infoHash,omitempty"`
Leechers *int `json:"leechers,omitempty"`
Message *string `json:"message,omitempty"`
PeerSamples *[]struct {
Address string `json:"address"`
Client *string `json:"client,omitempty"`
DownloadBps *int `json:"downloadBps,omitempty"`
Progress *float32 `json:"progress,omitempty"`
UploadBps *int `json:"uploadBps,omitempty"`
} `json:"peerSamples,omitempty"`
Peers *int `json:"peers,omitempty"`
Phase *string `json:"phase,omitempty"`
Seeders *int `json:"seeders,omitempty"`
TorrentName *string `json:"torrentName,omitempty"`
Trackers *[]struct {
Leechers *int `json:"leechers,omitempty"`
Message *string `json:"message,omitempty"`
Peers *int `json:"peers,omitempty"`
Seeds *int `json:"seeds,omitempty"`
Status *string `json:"status,omitempty"`
Url string `json:"url"`
} `json:"trackers,omitempty"`
UploadedBytes *int `json:"uploadedBytes,omitempty"`
} `json:"detail,omitempty"`
DownloadBps int64 `json:"downloadBps"`
DownloadedBytes int64 `json:"downloadedBytes"`
ErrorMessage *string `json:"errorMessage,omitempty"`
Id string `json:"id"`
Name string `json:"name"`
ResultObjectId *string `json:"resultObjectId,omitempty"`
SourceType GetApiDownloadTasks200JSONResponseBodyItemsSourceType `json:"sourceType"`
SourceUri string `json:"sourceUri"`
Status GetApiDownloadTasks200JSONResponseBodyItemsStatus `json:"status"`
TargetFolder string `json:"targetFolder"`
TotalBytes *int64 `json:"totalBytes"`
UploadBps int64 `json:"uploadBps"`
UploadToken *string `json:"uploadToken,omitempty"`
} `json:"items"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
@@ -2989,19 +3318,54 @@ func ParsePostApiDownloadTasksResponse(rsp *http.Response) (*PostApiDownloadTask
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201:
var dest struct {
AssignedDownloaderId *string `json:"assignedDownloaderId,omitempty"`
DownloadBps int64 `json:"downloadBps"`
DownloadedBytes int64 `json:"downloadedBytes"`
Id string `json:"id"`
Name string `json:"name"`
ResultObjectId *string `json:"resultObjectId,omitempty"`
SourceType PostApiDownloadTasks201JSONResponseBodySourceType `json:"sourceType"`
SourceUri string `json:"sourceUri"`
Status PostApiDownloadTasks201JSONResponseBodyStatus `json:"status"`
TargetFolder string `json:"targetFolder"`
TotalBytes *int64 `json:"totalBytes"`
UploadBps int64 `json:"uploadBps"`
UploadToken *string `json:"uploadToken,omitempty"`
AssignedDownloaderId *string `json:"assignedDownloaderId,omitempty"`
Detail *struct {
Connections *int `json:"connections,omitempty"`
Engine *PostApiDownloadTasks201JSONResponseBodyDetailEngine `json:"engine,omitempty"`
EtaSeconds *int `json:"etaSeconds,omitempty"`
Files *[]struct {
CompletedBytes *int `json:"completedBytes,omitempty"`
Path string `json:"path"`
Selected *bool `json:"selected,omitempty"`
Size int `json:"size"`
} `json:"files,omitempty"`
InfoHash *string `json:"infoHash,omitempty"`
Leechers *int `json:"leechers,omitempty"`
Message *string `json:"message,omitempty"`
PeerSamples *[]struct {
Address string `json:"address"`
Client *string `json:"client,omitempty"`
DownloadBps *int `json:"downloadBps,omitempty"`
Progress *float32 `json:"progress,omitempty"`
UploadBps *int `json:"uploadBps,omitempty"`
} `json:"peerSamples,omitempty"`
Peers *int `json:"peers,omitempty"`
Phase *string `json:"phase,omitempty"`
Seeders *int `json:"seeders,omitempty"`
TorrentName *string `json:"torrentName,omitempty"`
Trackers *[]struct {
Leechers *int `json:"leechers,omitempty"`
Message *string `json:"message,omitempty"`
Peers *int `json:"peers,omitempty"`
Seeds *int `json:"seeds,omitempty"`
Status *string `json:"status,omitempty"`
Url string `json:"url"`
} `json:"trackers,omitempty"`
UploadedBytes *int `json:"uploadedBytes,omitempty"`
} `json:"detail,omitempty"`
DownloadBps int64 `json:"downloadBps"`
DownloadedBytes int64 `json:"downloadedBytes"`
ErrorMessage *string `json:"errorMessage,omitempty"`
Id string `json:"id"`
Name string `json:"name"`
ResultObjectId *string `json:"resultObjectId,omitempty"`
SourceType PostApiDownloadTasks201JSONResponseBodySourceType `json:"sourceType"`
SourceUri string `json:"sourceUri"`
Status PostApiDownloadTasks201JSONResponseBodyStatus `json:"status"`
TargetFolder string `json:"targetFolder"`
TotalBytes *int64 `json:"totalBytes"`
UploadBps int64 `json:"uploadBps"`
UploadToken *string `json:"uploadToken,omitempty"`
}
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
@@ -3056,19 +3420,54 @@ func ParseGetApiDownloadTasksIdResponse(rsp *http.Response) (*GetApiDownloadTask
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest struct {
AssignedDownloaderId *string `json:"assignedDownloaderId,omitempty"`
DownloadBps int64 `json:"downloadBps"`
DownloadedBytes int64 `json:"downloadedBytes"`
Id string `json:"id"`
Name string `json:"name"`
ResultObjectId *string `json:"resultObjectId,omitempty"`
SourceType GetApiDownloadTasksId200JSONResponseBodySourceType `json:"sourceType"`
SourceUri string `json:"sourceUri"`
Status GetApiDownloadTasksId200JSONResponseBodyStatus `json:"status"`
TargetFolder string `json:"targetFolder"`
TotalBytes *int64 `json:"totalBytes"`
UploadBps int64 `json:"uploadBps"`
UploadToken *string `json:"uploadToken,omitempty"`
AssignedDownloaderId *string `json:"assignedDownloaderId,omitempty"`
Detail *struct {
Connections *int `json:"connections,omitempty"`
Engine *GetApiDownloadTasksId200JSONResponseBodyDetailEngine `json:"engine,omitempty"`
EtaSeconds *int `json:"etaSeconds,omitempty"`
Files *[]struct {
CompletedBytes *int `json:"completedBytes,omitempty"`
Path string `json:"path"`
Selected *bool `json:"selected,omitempty"`
Size int `json:"size"`
} `json:"files,omitempty"`
InfoHash *string `json:"infoHash,omitempty"`
Leechers *int `json:"leechers,omitempty"`
Message *string `json:"message,omitempty"`
PeerSamples *[]struct {
Address string `json:"address"`
Client *string `json:"client,omitempty"`
DownloadBps *int `json:"downloadBps,omitempty"`
Progress *float32 `json:"progress,omitempty"`
UploadBps *int `json:"uploadBps,omitempty"`
} `json:"peerSamples,omitempty"`
Peers *int `json:"peers,omitempty"`
Phase *string `json:"phase,omitempty"`
Seeders *int `json:"seeders,omitempty"`
TorrentName *string `json:"torrentName,omitempty"`
Trackers *[]struct {
Leechers *int `json:"leechers,omitempty"`
Message *string `json:"message,omitempty"`
Peers *int `json:"peers,omitempty"`
Seeds *int `json:"seeds,omitempty"`
Status *string `json:"status,omitempty"`
Url string `json:"url"`
} `json:"trackers,omitempty"`
UploadedBytes *int `json:"uploadedBytes,omitempty"`
} `json:"detail,omitempty"`
DownloadBps int64 `json:"downloadBps"`
DownloadedBytes int64 `json:"downloadedBytes"`
ErrorMessage *string `json:"errorMessage,omitempty"`
Id string `json:"id"`
Name string `json:"name"`
ResultObjectId *string `json:"resultObjectId,omitempty"`
SourceType GetApiDownloadTasksId200JSONResponseBodySourceType `json:"sourceType"`
SourceUri string `json:"sourceUri"`
Status GetApiDownloadTasksId200JSONResponseBodyStatus `json:"status"`
TargetFolder string `json:"targetFolder"`
TotalBytes *int64 `json:"totalBytes"`
UploadBps int64 `json:"uploadBps"`
UploadToken *string `json:"uploadToken,omitempty"`
}
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
@@ -3105,19 +3504,54 @@ func ParsePatchApiDownloadTasksIdResponse(rsp *http.Response) (*PatchApiDownload
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest struct {
AssignedDownloaderId *string `json:"assignedDownloaderId,omitempty"`
DownloadBps int64 `json:"downloadBps"`
DownloadedBytes int64 `json:"downloadedBytes"`
Id string `json:"id"`
Name string `json:"name"`
ResultObjectId *string `json:"resultObjectId,omitempty"`
SourceType PatchApiDownloadTasksId200JSONResponseBodySourceType `json:"sourceType"`
SourceUri string `json:"sourceUri"`
Status PatchApiDownloadTasksId200JSONResponseBodyStatus `json:"status"`
TargetFolder string `json:"targetFolder"`
TotalBytes *int64 `json:"totalBytes"`
UploadBps int64 `json:"uploadBps"`
UploadToken *string `json:"uploadToken,omitempty"`
AssignedDownloaderId *string `json:"assignedDownloaderId,omitempty"`
Detail *struct {
Connections *int `json:"connections,omitempty"`
Engine *PatchApiDownloadTasksId200JSONResponseBodyDetailEngine `json:"engine,omitempty"`
EtaSeconds *int `json:"etaSeconds,omitempty"`
Files *[]struct {
CompletedBytes *int `json:"completedBytes,omitempty"`
Path string `json:"path"`
Selected *bool `json:"selected,omitempty"`
Size int `json:"size"`
} `json:"files,omitempty"`
InfoHash *string `json:"infoHash,omitempty"`
Leechers *int `json:"leechers,omitempty"`
Message *string `json:"message,omitempty"`
PeerSamples *[]struct {
Address string `json:"address"`
Client *string `json:"client,omitempty"`
DownloadBps *int `json:"downloadBps,omitempty"`
Progress *float32 `json:"progress,omitempty"`
UploadBps *int `json:"uploadBps,omitempty"`
} `json:"peerSamples,omitempty"`
Peers *int `json:"peers,omitempty"`
Phase *string `json:"phase,omitempty"`
Seeders *int `json:"seeders,omitempty"`
TorrentName *string `json:"torrentName,omitempty"`
Trackers *[]struct {
Leechers *int `json:"leechers,omitempty"`
Message *string `json:"message,omitempty"`
Peers *int `json:"peers,omitempty"`
Seeds *int `json:"seeds,omitempty"`
Status *string `json:"status,omitempty"`
Url string `json:"url"`
} `json:"trackers,omitempty"`
UploadedBytes *int `json:"uploadedBytes,omitempty"`
} `json:"detail,omitempty"`
DownloadBps int64 `json:"downloadBps"`
DownloadedBytes int64 `json:"downloadedBytes"`
ErrorMessage *string `json:"errorMessage,omitempty"`
Id string `json:"id"`
Name string `json:"name"`
ResultObjectId *string `json:"resultObjectId,omitempty"`
SourceType PatchApiDownloadTasksId200JSONResponseBodySourceType `json:"sourceType"`
SourceUri string `json:"sourceUri"`
Status PatchApiDownloadTasksId200JSONResponseBodyStatus `json:"status"`
TargetFolder string `json:"targetFolder"`
TotalBytes *int64 `json:"totalBytes"`
UploadBps int64 `json:"uploadBps"`
UploadToken *string `json:"uploadToken,omitempty"`
}
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
+2 -1
View File
@@ -122,11 +122,12 @@ func (w *Worker) process(ctx context.Context, task client.DownloadTask) {
}
var lastProgressLog time.Time
result, err := w.engine.Download(ctx, task, func(downloaded int64, total *int64, bps int64) error {
result, err := w.engine.Download(ctx, task, func(downloaded int64, total *int64, bps int64, detail *client.DownloadTaskDetail) error {
_, updateErr := w.api.UpdateTask(ctx, task.ID, client.TaskPatch{
DownloadedBytes: &downloaded,
TotalBytes: total,
DownloadBps: &bps,
Detail: detail,
})
if updateErr != nil && strings.Contains(updateErr.Error(), "insufficient_credits") {
log.Warn("task paused by billing", "downloaded_bytes", downloaded, "total_bytes", optionalInt64(total), "bps", bps)
@@ -0,0 +1 @@
ALTER TABLE `download_tasks` ADD `detail` text;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -246,6 +246,13 @@
"when": 1780459022634,
"tag": "0035_puzzling_hemingway",
"breakpoints": true
},
{
"idx": 36,
"version": "6",
"when": 1780499047848,
"tag": "0036_add-download-task-detail",
"breakpoints": true
}
]
}
+1
View File
@@ -348,6 +348,7 @@ export const downloadTasks = sqliteTable(
uploadBps: integer('upload_bps').notNull().default(0),
errorMessage: text('error_message'),
resultObjectId: text('result_object_id'),
detail: text('detail'),
uploadTokenHash: text('upload_token_hash'),
uploadTokenJti: text('upload_token_jti'),
uploadTokenExpiresAt: integer('upload_token_expires_at', { mode: 'timestamp_ms' }),
@@ -203,9 +203,26 @@ describe('Download tasks API integration', () => {
downloadedBytes: 10 * 1024 * 1024,
totalBytes: 10 * 1024 * 1024,
downloadBps: 512_000,
detail: {
engine: 'aria2',
phase: 'active',
infoHash: 'abc123',
torrentName: 'fixture',
connections: 8,
seeders: 3,
trackers: [{ url: 'udp://tracker.example/announce', status: 'working', seeds: 3, peers: 8 }],
peerSamples: [{ address: '127.0.0.1:6881', client: 'libtorrent', progress: 0.5, downloadBps: 128_000 }],
files: [{ path: 'fixture.txt', size: 10 * 1024 * 1024, completedBytes: 5 * 1024 * 1024 }],
},
}),
})
expect(runningRes.status).toBe(200)
const runningTask = (await runningRes.json()) as {
detail: { engine: string; infoHash: string; trackers: Array<{ url: string }> }
}
expect(runningTask.detail.engine).toBe('aria2')
expect(runningTask.detail.infoHash).toBe('abc123')
expect(runningTask.detail.trackers[0].url).toBe('udp://tracker.example/announce')
const createObjectRes = await app.request('/api/objects', {
method: 'POST',
+8 -1
View File
@@ -1,5 +1,10 @@
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
import { createDownloadTaskSchema, listDownloadTasksQuerySchema, updateDownloadTaskSchema } from '@shared/schemas'
import {
createDownloadTaskSchema,
downloadTaskDetailSchema,
listDownloadTasksQuerySchema,
updateDownloadTaskSchema,
} from '@shared/schemas'
import type { Context } from 'hono'
import { requireAuth, requireTeamRole } from '../middleware/auth'
import type { Env } from '../middleware/platform'
@@ -38,7 +43,9 @@ const downloadTaskSchema = z.object({
totalBytes: nullableInt64Schema(),
downloadBps: int64Schema(),
uploadBps: int64Schema(),
errorMessage: z.string().nullable().optional(),
resultObjectId: z.string().nullable().optional(),
detail: downloadTaskDetailSchema.nullable().optional(),
uploadToken: z.string().optional(),
assignedDownloaderId: z.string().nullable().optional(),
})
+13 -1
View File
@@ -288,7 +288,8 @@ export async function updateDownloadTask(
input.downloadBps === undefined &&
input.uploadBps === undefined &&
input.errorMessage === undefined &&
input.resultObjectId === undefined
input.resultObjectId === undefined &&
input.detail === undefined
if (!onlyCancel) throw new DownloadError('forbidden')
}
@@ -350,6 +351,7 @@ export async function updateDownloadTask(
uploadBps: input.uploadBps ?? task.uploadBps,
errorMessage: input.errorMessage === undefined ? task.errorMessage : input.errorMessage,
resultObjectId: input.resultObjectId === undefined ? task.resultObjectId : input.resultObjectId,
detail: input.detail === undefined ? task.detail : JSON.stringify(input.detail),
startedAt: task.startedAt ?? (status === 'running' ? now : null),
finishedAt: ['completed', 'failed', 'canceled'].includes(status) ? now : task.finishedAt,
updatedAt: now,
@@ -511,6 +513,7 @@ function toDownloadTask(row: DownloadTaskRow): DownloadTask {
uploadBps: row.uploadBps,
errorMessage: row.errorMessage,
resultObjectId: row.resultObjectId,
detail: parseTaskDetail(row.detail),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
assignedAt: row.assignedAt?.toISOString() ?? null,
@@ -519,6 +522,15 @@ function toDownloadTask(row: DownloadTaskRow): DownloadTask {
}
}
function parseTaskDetail(value: string | null): DownloadTask['detail'] {
if (!value) return null
try {
return JSON.parse(value) as DownloadTask['detail']
} catch {
return null
}
}
function parseCapabilities(value: string): string[] {
try {
const parsed = JSON.parse(value)
+1
View File
@@ -437,6 +437,7 @@ const APP_SCHEMA_SQL = `
upload_bps INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
result_object_id TEXT,
detail TEXT,
upload_token_hash TEXT,
upload_token_jti TEXT,
upload_token_expires_at INTEGER,
+43
View File
@@ -14,6 +14,47 @@ export const downloadTaskStatusSchema = z.enum([
])
export const downloadSourceTypeSchema = z.enum(['http', 'magnet', 'torrent_url'])
const downloadTaskTrackerSchema = z.object({
url: z.string().max(1024),
status: z.string().max(80).optional(),
peers: z.number().int().min(0).optional(),
seeds: z.number().int().min(0).optional(),
leechers: z.number().int().min(0).optional(),
message: z.string().max(500).optional(),
})
const downloadTaskPeerSchema = z.object({
address: z.string().max(160),
client: z.string().max(160).optional(),
progress: z.number().min(0).max(1).optional(),
downloadBps: z.number().int().min(0).optional(),
uploadBps: z.number().int().min(0).optional(),
})
const downloadTaskFileSchema = z.object({
path: z.string().max(1024),
size: z.number().int().min(0),
completedBytes: z.number().int().min(0).optional(),
selected: z.boolean().optional(),
})
export const downloadTaskDetailSchema = z.object({
engine: downloaderEngineSchema.optional(),
phase: z.string().max(80).optional(),
message: z.string().max(500).optional(),
etaSeconds: z.number().int().min(0).nullable().optional(),
connections: z.number().int().min(0).optional(),
infoHash: z.string().max(120).optional(),
torrentName: z.string().max(255).optional(),
seeders: z.number().int().min(0).optional(),
leechers: z.number().int().min(0).optional(),
peers: z.number().int().min(0).optional(),
uploadedBytes: z.number().int().min(0).optional(),
trackers: z.array(downloadTaskTrackerSchema).max(20).optional(),
peerSamples: z.array(downloadTaskPeerSchema).max(20).optional(),
files: z.array(downloadTaskFileSchema).max(50).optional(),
})
export const downloaderHeartbeatSchema = z.object({
version: z.string().min(1).max(80),
hostname: z.string().min(1).max(160),
@@ -60,6 +101,7 @@ export const updateDownloadTaskSchema = z.object({
uploadBps: z.number().int().min(0).optional(),
errorMessage: z.string().max(1000).nullable().optional(),
resultObjectId: z.string().min(1).nullable().optional(),
detail: downloadTaskDetailSchema.nullable().optional(),
})
export const listDownloadTasksQuerySchema = z.object({
@@ -107,6 +149,7 @@ export type CreateDownloaderInput = z.infer<typeof createDownloaderSchema>
export type CreateDownloadTaskInput = z.infer<typeof createDownloadTaskSchema>
export type UpdateDownloadTaskInput = z.infer<typeof updateDownloadTaskSchema>
export type ListDownloadTasksQuery = z.infer<typeof listDownloadTasksQuerySchema>
export type DownloadTaskDetail = z.infer<typeof downloadTaskDetailSchema>
export type CreateObjectUploadSessionInput = z.infer<typeof createObjectUploadSessionSchema>
export type PresignObjectUploadPartsInput = z.infer<typeof presignObjectUploadPartsSchema>
export type PatchObjectUploadSessionInput = z.infer<typeof patchObjectUploadSessionSchema>
+2
View File
@@ -76,6 +76,7 @@ export type {
CreateDownloadTaskInput,
CreateObjectUploadSessionInput,
DownloaderHeartbeatInput,
DownloadTaskDetail,
ListDownloadTasksQuery,
PatchObjectUploadSessionInput,
PresignObjectUploadPartsInput,
@@ -90,6 +91,7 @@ export {
downloaderHeartbeatSchema,
downloaderStatusSchema,
downloadSourceTypeSchema,
downloadTaskDetailSchema,
downloadTaskStatusSchema,
listDownloadTasksQuerySchema,
patchObjectUploadSessionSchema,
+42
View File
@@ -261,6 +261,7 @@ export interface DownloadTask {
uploadBps: number
errorMessage: string | null
resultObjectId: string | null
detail: DownloadTaskDetail | null
uploadToken?: string
createdAt: string
updatedAt: string
@@ -269,6 +270,47 @@ export interface DownloadTask {
finishedAt: string | null
}
export interface DownloadTaskTracker {
url: string
status?: string
peers?: number
seeds?: number
leechers?: number
message?: string
}
export interface DownloadTaskPeer {
address: string
client?: string
progress?: number
downloadBps?: number
uploadBps?: number
}
export interface DownloadTaskFile {
path: string
size: number
completedBytes?: number
selected?: boolean
}
export interface DownloadTaskDetail {
engine?: Downloader['engine']
phase?: string
message?: string
etaSeconds?: number | null
connections?: number
infoHash?: string
torrentName?: string
seeders?: number
leechers?: number
peers?: number
uploadedBytes?: number
trackers?: DownloadTaskTracker[]
peerSamples?: DownloadTaskPeer[]
files?: DownloadTaskFile[]
}
export interface ObjectUploadSession {
id: string
objectId: string
+20
View File
@@ -146,6 +146,26 @@
"downloads.status.completed": "Completed",
"downloads.status.failed": "Failed",
"downloads.status.canceled": "Canceled",
"downloads.detail.downloadSpeed": "Download speed",
"downloads.detail.uploadSpeed": "Upload speed",
"downloads.detail.connections": "Connections",
"downloads.detail.eta": "ETA",
"downloads.detail.progress": "Progress",
"downloads.detail.target": "Target folder",
"downloads.detail.engine": "Engine",
"downloads.detail.billing": "Billing",
"downloads.detail.bt": "BT details",
"downloads.detail.infoHash": "Info hash",
"downloads.detail.seeders": "Seeders",
"downloads.detail.leechers": "Leechers",
"downloads.detail.uploaded": "Uploaded",
"downloads.detail.trackers": "Trackers",
"downloads.detail.noTrackers": "No tracker data yet",
"downloads.detail.peers": "Peers",
"downloads.detail.noPeers": "No peer data yet",
"downloads.detail.files": "Files",
"downloads.detail.noFiles": "No file data yet",
"downloads.detail.skipped": "Skipped",
"uploadPanel.toggle": "Upload progress",
"uploadPanel.title": "Uploads",
"uploadPanel.empty": "No uploads yet",
+20
View File
@@ -146,6 +146,26 @@
"downloads.status.completed": "已完成",
"downloads.status.failed": "失败",
"downloads.status.canceled": "已取消",
"downloads.detail.downloadSpeed": "下载速度",
"downloads.detail.uploadSpeed": "上传速度",
"downloads.detail.connections": "连接数",
"downloads.detail.eta": "剩余时间",
"downloads.detail.progress": "完成度",
"downloads.detail.target": "目标目录",
"downloads.detail.engine": "下载引擎",
"downloads.detail.billing": "计费",
"downloads.detail.bt": "BT 信息",
"downloads.detail.infoHash": "Info hash",
"downloads.detail.seeders": "做种",
"downloads.detail.leechers": "下载者",
"downloads.detail.uploaded": "已上传",
"downloads.detail.trackers": "Trackers",
"downloads.detail.noTrackers": "暂无 tracker 信息",
"downloads.detail.peers": "Peers",
"downloads.detail.noPeers": "暂无 peer 信息",
"downloads.detail.files": "文件",
"downloads.detail.noFiles": "暂无文件信息",
"downloads.detail.skipped": "跳过",
"uploadPanel.toggle": "上传进度",
"uploadPanel.title": "上传",
"uploadPanel.empty": "暂无上传任务",
+253 -38
View File
@@ -3,20 +3,27 @@ import type { DownloadTask, DownloadTaskStatus, StorageObject } from '@shared/ty
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import {
AlertCircle,
Check,
ChevronDown,
ChevronRight,
Clock,
Download,
FileDown,
Folder,
FolderInput,
Gauge,
Home,
LinkIcon,
Magnet,
PauseCircle,
Plus,
RadioTower,
RotateCw,
Upload,
Users,
} from 'lucide-react'
import { type FormEvent, useState } from 'react'
import { type FormEvent, Fragment, type ReactNode, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { useFilesQuery } from '@/components/files/hooks/use-files-query'
@@ -48,6 +55,7 @@ function DownloadsPage() {
const [targetFolder, setTargetFolder] = useState('')
const [name, setName] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [expandedTaskId, setExpandedTaskId] = useState<string | null>(null)
const tasksQuery = useQuery({
queryKey: QUERY_KEY,
@@ -198,7 +206,13 @@ function DownloadsPage() {
</TableRow>
)}
{tasks.map((task) => (
<TaskRow key={task.id} task={task} onCancel={(id) => pauseMutation.mutate(id)} />
<TaskRow
key={task.id}
task={task}
expanded={expandedTaskId === task.id}
onToggle={() => setExpandedTaskId((current) => (current === task.id ? null : task.id))}
onCancel={(id) => pauseMutation.mutate(id)}
/>
))}
</TableBody>
</Table>
@@ -319,51 +333,238 @@ function FolderPicker({ value, onChange }: { value: string; onChange: (path: str
)
}
function TaskRow({ task, onCancel }: { task: DownloadTask; onCancel: (id: string) => void }) {
function TaskRow({
task,
expanded,
onToggle,
onCancel,
}: {
task: DownloadTask
expanded: boolean
onToggle: () => void
onCancel: (id: string) => void
}) {
const { t } = useTranslation()
const total = task.totalBytes ?? task.downloadedBytes
const progress = total > 0 ? Math.min(100, Math.round((task.downloadedBytes / total) * 100)) : 0
const active = ACTIVE_STATUSES.has(task.status)
return (
<TableRow>
<TableCell className="max-w-[32rem]">
<div className="flex items-center gap-2">
<LinkIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="truncate font-medium">{task.name || task.sourceUri}</div>
<div className="truncate text-xs text-muted-foreground">{task.sourceUri}</div>
<Fragment>
<TableRow>
<TableCell className="max-w-[32rem]">
<div className="flex items-center gap-2">
<Button type="button" variant="ghost" size="icon" className="size-8 shrink-0" onClick={onToggle}>
{expanded ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
</Button>
<LinkIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<div className="truncate font-medium">{task.detail?.torrentName || task.name || task.sourceUri}</div>
<div className="truncate text-xs text-muted-foreground">{task.sourceUri}</div>
</div>
</div>
</TableCell>
<TableCell>
<StatusBadge status={task.status} />
{task.detail?.phase && <div className="mt-1 text-xs text-muted-foreground">{task.detail.phase}</div>}
{task.status === 'billing_paused' && (
<div className="mt-1 text-xs text-muted-foreground">{t('downloads.billingPaused')}</div>
)}
</TableCell>
<TableCell className="min-w-44">
<div className="flex items-center gap-2">
<Progress value={progress} className="h-2" />
<span className="w-10 text-right text-xs text-muted-foreground">{progress}%</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatBytes(task.downloadedBytes)} /{' '}
{task.totalBytes ? formatBytes(task.totalBytes) : t('downloads.unknown')}
</div>
</TableCell>
<TableCell>
<div className="text-sm">{formatBytes(task.downloadBps)}/s</div>
{task.uploadBps > 0 && (
<div className="text-xs text-muted-foreground">{formatBytes(task.uploadBps)}/s up</div>
)}
</TableCell>
<TableCell className="text-right">
{active ? (
<Button variant="ghost" size="sm" onClick={() => onCancel(task.id)}>
<PauseCircle className="size-4" />
{t('downloads.cancel')}
</Button>
) : (
<RotateCw className="ml-auto size-4 text-muted-foreground" />
)}
</TableCell>
</TableRow>
{expanded && (
<TableRow>
<TableCell colSpan={5} className="bg-muted/20 p-0">
<TaskDetails task={task} progress={progress} />
</TableCell>
</TableRow>
)}
</Fragment>
)
}
function TaskDetails({ task, progress }: { task: DownloadTask; progress: number }) {
const { t } = useTranslation()
const detail = task.detail
const hasBtDetail = Boolean(
detail?.infoHash || detail?.trackers?.length || detail?.peerSamples?.length || detail?.files?.length,
)
return (
<div className="space-y-4 p-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Metric
icon={<Gauge className="size-4" />}
label={t('downloads.detail.downloadSpeed')}
value={`${formatBytes(task.downloadBps)}/s`}
/>
<Metric
icon={<Upload className="size-4" />}
label={t('downloads.detail.uploadSpeed')}
value={`${formatBytes(task.uploadBps)}/s`}
/>
<Metric
icon={<Users className="size-4" />}
label={t('downloads.detail.connections')}
value={formatNumber(detail?.connections)}
/>
<Metric
icon={<Clock className="size-4" />}
label={t('downloads.detail.eta')}
value={formatDuration(detail?.etaSeconds)}
/>
</div>
<div className="grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-4">
<DetailItem label={t('downloads.detail.progress')} value={`${progress}%`} />
<DetailItem label={t('downloads.detail.target')} value={task.targetFolder || t('downloads.targetFolderRoot')} />
<DetailItem label={t('downloads.detail.engine')} value={detail?.engine || t('downloads.unknown')} />
<DetailItem
label={t('downloads.detail.billing')}
value={`${formatBytes(task.billedBytes)} / ${task.billedCredits} credits`}
/>
</div>
{task.errorMessage && (
<div className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0 break-words">{task.errorMessage}</div>
</div>
)}
{hasBtDetail && (
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-3">
<SectionTitle icon={<Magnet className="size-4" />} title={t('downloads.detail.bt')} />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<DetailItem label={t('downloads.detail.infoHash')} value={detail?.infoHash || t('downloads.unknown')} />
<DetailItem label={t('downloads.detail.seeders')} value={formatNumber(detail?.seeders)} />
<DetailItem label={t('downloads.detail.leechers')} value={formatNumber(detail?.leechers)} />
<DetailItem label={t('downloads.detail.uploaded')} value={formatBytes(detail?.uploadedBytes ?? 0)} />
</div>
</div>
<div className="space-y-3">
<SectionTitle icon={<RadioTower className="size-4" />} title={t('downloads.detail.trackers')} />
<CompactList
empty={t('downloads.detail.noTrackers')}
items={(detail?.trackers ?? []).slice(0, 8).map((tracker) => ({
key: tracker.url,
primary: tracker.url,
secondary: [tracker.status, tracker.message].filter(Boolean).join(' · '),
meta: `${formatNumber(tracker.seeds)} / ${formatNumber(tracker.peers)}`,
}))}
/>
</div>
<div className="space-y-3">
<SectionTitle icon={<Users className="size-4" />} title={t('downloads.detail.peers')} />
<CompactList
empty={t('downloads.detail.noPeers')}
items={(detail?.peerSamples ?? []).slice(0, 8).map((peer) => ({
key: peer.address,
primary: peer.address,
secondary: peer.client,
meta: `${formatBytes(peer.downloadBps ?? 0)}/s`,
}))}
/>
</div>
<div className="space-y-3">
<SectionTitle icon={<FileDown className="size-4" />} title={t('downloads.detail.files')} />
<CompactList
empty={t('downloads.detail.noFiles')}
items={(detail?.files ?? []).slice(0, 8).map((file) => ({
key: file.path,
primary: file.path,
secondary: `${formatBytes(file.completedBytes ?? 0)} / ${formatBytes(file.size)}`,
meta: file.selected === false ? t('downloads.detail.skipped') : '',
}))}
/>
</div>
</div>
</TableCell>
<TableCell>
<StatusBadge status={task.status} />
{task.status === 'billing_paused' && (
<div className="mt-1 text-xs text-muted-foreground">{t('downloads.billingPaused')}</div>
)}
</TableCell>
<TableCell className="min-w-44">
<div className="flex items-center gap-2">
<Progress value={progress} className="h-2" />
<span className="w-10 text-right text-xs text-muted-foreground">{progress}%</span>
)}
</div>
)
}
function Metric({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
return (
<div className="rounded-md border bg-background px-3 py-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{icon}
{label}
</div>
<div className="mt-1 truncate text-sm font-medium">{value}</div>
</div>
)
}
function DetailItem({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="truncate font-medium">{value}</div>
</div>
)
}
function SectionTitle({ icon, title }: { icon: ReactNode; title: string }) {
return (
<div className="flex items-center gap-2 text-sm font-medium">
{icon}
{title}
</div>
)
}
function CompactList({
items,
empty,
}: {
items: Array<{ key: string; primary: string; secondary?: string; meta?: string }>
empty: string
}) {
if (items.length === 0)
return <div className="rounded-md border bg-background p-3 text-sm text-muted-foreground">{empty}</div>
return (
<div className="rounded-md border bg-background">
{items.map((item) => (
<div key={item.key} className="flex min-w-0 items-center gap-3 border-b px-3 py-2 text-sm last:border-b-0">
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{item.primary}</div>
{item.secondary && <div className="truncate text-xs text-muted-foreground">{item.secondary}</div>}
</div>
{item.meta && <div className="shrink-0 text-xs text-muted-foreground">{item.meta}</div>}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatBytes(task.downloadedBytes)} /{' '}
{task.totalBytes ? formatBytes(task.totalBytes) : t('downloads.unknown')}
</div>
</TableCell>
<TableCell>{formatBytes(task.downloadBps)}/s</TableCell>
<TableCell className="text-right">
{active ? (
<Button variant="ghost" size="sm" onClick={() => onCancel(task.id)}>
<PauseCircle className="size-4" />
{t('downloads.cancel')}
</Button>
) : (
<RotateCw className="ml-auto size-4 text-muted-foreground" />
)}
</TableCell>
</TableRow>
))}
</div>
)
}
@@ -385,3 +586,17 @@ function formatBytes(bytes: number) {
}
return `${value >= 10 || unit === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unit]}`
}
function formatNumber(value: number | null | undefined) {
if (!Number.isFinite(value)) return '-'
return new Intl.NumberFormat().format(value as number)
}
function formatDuration(seconds: number | null | undefined) {
if (!Number.isFinite(seconds) || seconds === null || seconds === undefined) return '-'
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ${seconds % 60}s`
const hours = Math.floor(minutes / 60)
return `${hours}h ${minutes % 60}m`
}