mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-01 15:49:00 +08:00
feat(downloads): show normalized task details
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "暂无上传任务",
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user