mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
fix(downloads): separate list data from task details (#525)
* fix(downloads): separate list items from task details * fix(downloads): scope task list to downloader * fix(downloads): stabilize table data
This commit is contained in:
@@ -359,29 +359,27 @@ func (c *Client) AssignedControlTasks(ctx context.Context) ([]DownloadTask, erro
|
||||
|
||||
func (c *Client) assignedTasksByStatuses(ctx context.Context, statuses []string) ([]DownloadTask, error) {
|
||||
pageSize := 100
|
||||
assignedTo := openapi.Me
|
||||
status := strings.Join(statuses, ",")
|
||||
var pageToken *string
|
||||
var tasks []DownloadTask
|
||||
for {
|
||||
res, err := c.api.ListDownloadTasksWithResponse(ctx, &openapi.ListDownloadTasksParams{
|
||||
AssignedTo: &assignedTo,
|
||||
Status: &status,
|
||||
PageSize: &pageSize,
|
||||
PageToken: pageToken,
|
||||
res, err := c.api.ListDownloaderTasksWithResponse(ctx, &openapi.ListDownloaderTasksParams{
|
||||
Status: &status,
|
||||
PageSize: &pageSize,
|
||||
PageToken: pageToken,
|
||||
}, bearer(c.token))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := expectStatus("GET", "/api/downloads/tasks", res.StatusCode(), res.Body, http.StatusOK); err != nil {
|
||||
if err := expectStatus("GET", "/api/downloads/downloaders/me/tasks", res.StatusCode(), res.Body, http.StatusOK); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.JSON200 == nil {
|
||||
return nil, fmt.Errorf("GET /api/downloads/tasks failed: empty response")
|
||||
return nil, fmt.Errorf("GET /api/downloads/downloaders/me/tasks failed: empty response")
|
||||
}
|
||||
pageTasks, err := downloadTasksFromOpenAPI(res.JSON200.Items)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GET /api/downloads/tasks failed: %w", err)
|
||||
return nil, fmt.Errorf("GET /api/downloads/downloaders/me/tasks failed: %w", err)
|
||||
}
|
||||
tasks = append(tasks, pageTasks...)
|
||||
pageToken = res.JSON200.NextPageToken
|
||||
|
||||
@@ -167,7 +167,7 @@ func TestAssignedTasksFetchesRunnableStatuses(t *testing.T) {
|
||||
var status string
|
||||
var requests int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/downloads/tasks" {
|
||||
if r.URL.Path != "/api/downloads/downloaders/me/tasks" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
requests++
|
||||
@@ -232,8 +232,8 @@ func TestAssignedControlTasksFetchesControlStatuses(t *testing.T) {
|
||||
func TestLocalResultTasksFetchesRetryableStatuses(t *testing.T) {
|
||||
var status string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("assignedTo") != "me" {
|
||||
t.Fatalf("expected assignedTo=me, got %q", r.URL.Query().Get("assignedTo"))
|
||||
if r.URL.Path != "/api/downloads/downloaders/me/tasks" {
|
||||
t.Fatalf("expected assigned tasks path, got %q", r.URL.Path)
|
||||
}
|
||||
status = r.URL.Query().Get("status")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -608,7 +608,7 @@ func TestClientErrorResponsesIncludeProblemBody(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected assigned tasks error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "GET /api/downloads/tasks failed") || !strings.Contains(err.Error(), "not authorized") {
|
||||
if !strings.Contains(err.Error(), "GET /api/downloads/downloaders/me/tasks failed") || !strings.Contains(err.Error(), "not authorized") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +249,105 @@ func (e DownloadTaskStatusState) Valid() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for DownloadTaskListItemSpecSourceType.
|
||||
const (
|
||||
DownloadTaskListItemSpecSourceTypeHttp DownloadTaskListItemSpecSourceType = "http"
|
||||
DownloadTaskListItemSpecSourceTypeMagnet DownloadTaskListItemSpecSourceType = "magnet"
|
||||
DownloadTaskListItemSpecSourceTypeTorrentUrl DownloadTaskListItemSpecSourceType = "torrent_url"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the DownloadTaskListItemSpecSourceType enum.
|
||||
func (e DownloadTaskListItemSpecSourceType) Valid() bool {
|
||||
switch e {
|
||||
case DownloadTaskListItemSpecSourceTypeHttp:
|
||||
return true
|
||||
case DownloadTaskListItemSpecSourceTypeMagnet:
|
||||
return true
|
||||
case DownloadTaskListItemSpecSourceTypeTorrentUrl:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for DownloadTaskListItemStatusRuntimePhase.
|
||||
const (
|
||||
DownloadTaskListItemStatusRuntimePhaseCompleted DownloadTaskListItemStatusRuntimePhase = "completed"
|
||||
DownloadTaskListItemStatusRuntimePhaseDownloading DownloadTaskListItemStatusRuntimePhase = "downloading"
|
||||
DownloadTaskListItemStatusRuntimePhaseError DownloadTaskListItemStatusRuntimePhase = "error"
|
||||
DownloadTaskListItemStatusRuntimePhaseMetadata DownloadTaskListItemStatusRuntimePhase = "metadata"
|
||||
DownloadTaskListItemStatusRuntimePhaseSeeding DownloadTaskListItemStatusRuntimePhase = "seeding"
|
||||
DownloadTaskListItemStatusRuntimePhaseUploading DownloadTaskListItemStatusRuntimePhase = "uploading"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the DownloadTaskListItemStatusRuntimePhase enum.
|
||||
func (e DownloadTaskListItemStatusRuntimePhase) Valid() bool {
|
||||
switch e {
|
||||
case DownloadTaskListItemStatusRuntimePhaseCompleted:
|
||||
return true
|
||||
case DownloadTaskListItemStatusRuntimePhaseDownloading:
|
||||
return true
|
||||
case DownloadTaskListItemStatusRuntimePhaseError:
|
||||
return true
|
||||
case DownloadTaskListItemStatusRuntimePhaseMetadata:
|
||||
return true
|
||||
case DownloadTaskListItemStatusRuntimePhaseSeeding:
|
||||
return true
|
||||
case DownloadTaskListItemStatusRuntimePhaseUploading:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for DownloadTaskListItemStatusState.
|
||||
const (
|
||||
DownloadTaskListItemStatusStateAssigned DownloadTaskListItemStatusState = "assigned"
|
||||
DownloadTaskListItemStatusStateCanceled DownloadTaskListItemStatusState = "canceled"
|
||||
DownloadTaskListItemStatusStateCanceling DownloadTaskListItemStatusState = "canceling"
|
||||
DownloadTaskListItemStatusStateCompleted DownloadTaskListItemStatusState = "completed"
|
||||
DownloadTaskListItemStatusStateDownloading DownloadTaskListItemStatusState = "downloading"
|
||||
DownloadTaskListItemStatusStateFailed DownloadTaskListItemStatusState = "failed"
|
||||
DownloadTaskListItemStatusStateInterrupted DownloadTaskListItemStatusState = "interrupted"
|
||||
DownloadTaskListItemStatusStatePaused DownloadTaskListItemStatusState = "paused"
|
||||
DownloadTaskListItemStatusStatePausing DownloadTaskListItemStatusState = "pausing"
|
||||
DownloadTaskListItemStatusStateQueued DownloadTaskListItemStatusState = "queued"
|
||||
DownloadTaskListItemStatusStateSuspended DownloadTaskListItemStatusState = "suspended"
|
||||
DownloadTaskListItemStatusStateUploading DownloadTaskListItemStatusState = "uploading"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the DownloadTaskListItemStatusState enum.
|
||||
func (e DownloadTaskListItemStatusState) Valid() bool {
|
||||
switch e {
|
||||
case DownloadTaskListItemStatusStateAssigned:
|
||||
return true
|
||||
case DownloadTaskListItemStatusStateCanceled:
|
||||
return true
|
||||
case DownloadTaskListItemStatusStateCanceling:
|
||||
return true
|
||||
case DownloadTaskListItemStatusStateCompleted:
|
||||
return true
|
||||
case DownloadTaskListItemStatusStateDownloading:
|
||||
return true
|
||||
case DownloadTaskListItemStatusStateFailed:
|
||||
return true
|
||||
case DownloadTaskListItemStatusStateInterrupted:
|
||||
return true
|
||||
case DownloadTaskListItemStatusStatePaused:
|
||||
return true
|
||||
case DownloadTaskListItemStatusStatePausing:
|
||||
return true
|
||||
case DownloadTaskListItemStatusStateQueued:
|
||||
return true
|
||||
case DownloadTaskListItemStatusStateSuspended:
|
||||
return true
|
||||
case DownloadTaskListItemStatusStateUploading:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for DownloadTaskTimelineItemSeverity.
|
||||
const (
|
||||
DownloadTaskTimelineItemSeverityError DownloadTaskTimelineItemSeverity = "error"
|
||||
@@ -876,21 +975,6 @@ func (e RecordDownloaderHeartbeatJSONBodyEngine) Valid() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for ListDownloadTasksParamsAssignedTo.
|
||||
const (
|
||||
Me ListDownloadTasksParamsAssignedTo = "me"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the ListDownloadTasksParamsAssignedTo enum.
|
||||
func (e ListDownloadTasksParamsAssignedTo) Valid() bool {
|
||||
switch e {
|
||||
case Me:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for CreateDownloadTaskJSONBodySourceType.
|
||||
const (
|
||||
CreateDownloadTaskJSONBodySourceTypeHttp CreateDownloadTaskJSONBodySourceType = "http"
|
||||
@@ -1385,13 +1469,13 @@ func (e SaveEmailConfigJSONBody0Provider) Valid() bool {
|
||||
|
||||
// Defines values for SaveEmailConfigJSONBody1Provider.
|
||||
const (
|
||||
Http SaveEmailConfigJSONBody1Provider = "http"
|
||||
SaveEmailConfigJSONBody1ProviderHttp SaveEmailConfigJSONBody1Provider = "http"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the SaveEmailConfigJSONBody1Provider enum.
|
||||
func (e SaveEmailConfigJSONBody1Provider) Valid() bool {
|
||||
switch e {
|
||||
case Http:
|
||||
case SaveEmailConfigJSONBody1ProviderHttp:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -1994,6 +2078,67 @@ type DownloadTaskStatusRuntimePhase string
|
||||
// DownloadTaskStatusState defines model for DownloadTask.Status.State.
|
||||
type DownloadTaskStatusState string
|
||||
|
||||
// DownloadTaskListItem defines model for DownloadTaskListItem.
|
||||
type DownloadTaskListItem struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Id string `json:"id"`
|
||||
Spec struct {
|
||||
Destination struct {
|
||||
Folder string `json:"folder"`
|
||||
Name *string `json:"name"`
|
||||
} `json:"destination"`
|
||||
Labels struct {
|
||||
Category *string `json:"category"`
|
||||
Tags []string `json:"tags"`
|
||||
} `json:"labels"`
|
||||
Source struct {
|
||||
Type DownloadTaskListItemSpecSourceType `json:"type"`
|
||||
Uri string `json:"uri"`
|
||||
} `json:"source"`
|
||||
} `json:"spec"`
|
||||
Status struct {
|
||||
Progress struct {
|
||||
Download struct {
|
||||
Bytes int64 `json:"bytes"`
|
||||
BytesPerSecond int64 `json:"bytesPerSecond"`
|
||||
TotalBytes *int64 `json:"totalBytes,omitempty"`
|
||||
} `json:"download"`
|
||||
Upload struct {
|
||||
Bytes int64 `json:"bytes"`
|
||||
BytesPerSecond int64 `json:"bytesPerSecond"`
|
||||
TotalBytes *int64 `json:"totalBytes,omitempty"`
|
||||
} `json:"upload"`
|
||||
} `json:"progress"`
|
||||
Runtime *struct {
|
||||
EtaSeconds *int `json:"etaSeconds,omitempty"`
|
||||
Phase *DownloadTaskListItemStatusRuntimePhase `json:"phase,omitempty"`
|
||||
Torrent *struct {
|
||||
InfoHash *string `json:"infoHash,omitempty"`
|
||||
Leechers *int `json:"leechers,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Peers *int `json:"peers,omitempty"`
|
||||
Seeders *int `json:"seeders,omitempty"`
|
||||
} `json:"torrent,omitempty"`
|
||||
} `json:"runtime"`
|
||||
State DownloadTaskListItemStatusState `json:"state"`
|
||||
} `json:"status"`
|
||||
}
|
||||
|
||||
// DownloadTaskListItemSpecSourceType defines model for DownloadTaskListItem.Spec.Source.Type.
|
||||
type DownloadTaskListItemSpecSourceType string
|
||||
|
||||
// DownloadTaskListItemStatusRuntimePhase defines model for DownloadTaskListItem.Status.Runtime.Phase.
|
||||
type DownloadTaskListItemStatusRuntimePhase string
|
||||
|
||||
// DownloadTaskListItemStatusState defines model for DownloadTaskListItem.Status.State.
|
||||
type DownloadTaskListItemStatusState string
|
||||
|
||||
// DownloadTaskListPage defines model for DownloadTaskListPage.
|
||||
type DownloadTaskListPage struct {
|
||||
Items []DownloadTaskListItem `json:"items"`
|
||||
NextPageToken *string `json:"nextPageToken"`
|
||||
}
|
||||
|
||||
// DownloadTaskPage defines model for DownloadTaskPage.
|
||||
type DownloadTaskPage struct {
|
||||
Items []DownloadTask `json:"items"`
|
||||
@@ -3711,6 +3856,15 @@ type RecordDownloaderHeartbeatJSONBody struct {
|
||||
// RecordDownloaderHeartbeatJSONBodyEngine defines parameters for RecordDownloaderHeartbeat.
|
||||
type RecordDownloaderHeartbeatJSONBodyEngine string
|
||||
|
||||
// ListDownloaderTasksParams defines parameters for ListDownloaderTasks.
|
||||
type ListDownloaderTasksParams struct {
|
||||
Status *string `form:"status,omitempty" json:"status,omitempty"`
|
||||
Category *string `form:"category,omitempty" json:"category,omitempty"`
|
||||
Tag *string `form:"tag,omitempty" json:"tag,omitempty"`
|
||||
PageSize *int `form:"pageSize,omitempty" json:"pageSize,omitempty"`
|
||||
PageToken *string `form:"pageToken,omitempty" json:"pageToken,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateDownloaderJSONBody defines parameters for UpdateDownloader.
|
||||
type UpdateDownloaderJSONBody struct {
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
@@ -3729,17 +3883,13 @@ type UpdateDownloaderCreditBillingJSONBody struct {
|
||||
|
||||
// ListDownloadTasksParams defines parameters for ListDownloadTasks.
|
||||
type ListDownloadTasksParams struct {
|
||||
Status *string `form:"status,omitempty" json:"status,omitempty"`
|
||||
AssignedTo *ListDownloadTasksParamsAssignedTo `form:"assignedTo,omitempty" json:"assignedTo,omitempty"`
|
||||
Category *string `form:"category,omitempty" json:"category,omitempty"`
|
||||
Tag *string `form:"tag,omitempty" json:"tag,omitempty"`
|
||||
PageSize *int `form:"pageSize,omitempty" json:"pageSize,omitempty"`
|
||||
PageToken *string `form:"pageToken,omitempty" json:"pageToken,omitempty"`
|
||||
Status *string `form:"status,omitempty" json:"status,omitempty"`
|
||||
Category *string `form:"category,omitempty" json:"category,omitempty"`
|
||||
Tag *string `form:"tag,omitempty" json:"tag,omitempty"`
|
||||
PageSize *int `form:"pageSize,omitempty" json:"pageSize,omitempty"`
|
||||
PageToken *string `form:"pageToken,omitempty" json:"pageToken,omitempty"`
|
||||
}
|
||||
|
||||
// ListDownloadTasksParamsAssignedTo defines parameters for ListDownloadTasks.
|
||||
type ListDownloadTasksParamsAssignedTo string
|
||||
|
||||
// CreateDownloadTaskJSONBody defines parameters for CreateDownloadTask.
|
||||
type CreateDownloadTaskJSONBody struct {
|
||||
Category *string `json:"category,omitempty"`
|
||||
@@ -5385,6 +5535,9 @@ type ClientInterface interface {
|
||||
|
||||
RecordDownloaderHeartbeat(ctx context.Context, body RecordDownloaderHeartbeatJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// ListDownloaderTasks request
|
||||
ListDownloaderTasks(ctx context.Context, params *ListDownloaderTasksParams, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// DeleteDownloader request
|
||||
DeleteDownloader(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
@@ -7724,6 +7877,18 @@ func (c *Client) RecordDownloaderHeartbeat(ctx context.Context, body RecordDownl
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) ListDownloaderTasks(ctx context.Context, params *ListDownloaderTasksParams, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewListDownloaderTasksRequest(c.Server, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) DeleteDownloader(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewDeleteDownloaderRequest(c.Server, id)
|
||||
if err != nil {
|
||||
@@ -13680,6 +13845,108 @@ func NewRecordDownloaderHeartbeatRequestWithBody(server string, contentType stri
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewListDownloaderTasksRequest generates requests for ListDownloaderTasks
|
||||
func NewListDownloaderTasksRequest(server string, params *ListDownloaderTasksParams) (*http.Request, error) {
|
||||
var err error
|
||||
|
||||
serverURL, err := url.Parse(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
operationPath := fmt.Sprintf("/api/downloads/downloaders/me/tasks")
|
||||
if operationPath[0] == '/' {
|
||||
operationPath = "." + operationPath
|
||||
}
|
||||
|
||||
queryURL, err := serverURL.Parse(operationPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if params != nil {
|
||||
// queryValues collects non-styled parameters (passthrough, JSON)
|
||||
// that are safe to round-trip through url.Values.Encode().
|
||||
queryValues := queryURL.Query()
|
||||
// rawQueryFragments collects pre-encoded query fragments from
|
||||
// styled parameters, preserving literal commas as delimiters
|
||||
// per the OpenAPI spec (e.g. "color=blue,black,brown").
|
||||
var rawQueryFragments []string
|
||||
|
||||
if params.Status != nil {
|
||||
|
||||
if queryFrag, err := runtime.StyleParamWithOptions("form", true, "status", *params.Status, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
for _, qp := range strings.Split(queryFrag, "&") {
|
||||
rawQueryFragments = append(rawQueryFragments, qp)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if params.Category != nil {
|
||||
|
||||
if queryFrag, err := runtime.StyleParamWithOptions("form", true, "category", *params.Category, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
for _, qp := range strings.Split(queryFrag, "&") {
|
||||
rawQueryFragments = append(rawQueryFragments, qp)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if params.Tag != nil {
|
||||
|
||||
if queryFrag, err := runtime.StyleParamWithOptions("form", true, "tag", *params.Tag, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
for _, qp := range strings.Split(queryFrag, "&") {
|
||||
rawQueryFragments = append(rawQueryFragments, qp)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if params.PageSize != nil {
|
||||
|
||||
if queryFrag, err := runtime.StyleParamWithOptions("form", true, "pageSize", *params.PageSize, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
for _, qp := range strings.Split(queryFrag, "&") {
|
||||
rawQueryFragments = append(rawQueryFragments, qp)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if params.PageToken != nil {
|
||||
|
||||
if queryFrag, err := runtime.StyleParamWithOptions("form", true, "pageToken", *params.PageToken, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
for _, qp := range strings.Split(queryFrag, "&") {
|
||||
rawQueryFragments = append(rawQueryFragments, qp)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if encoded := queryValues.Encode(); encoded != "" {
|
||||
rawQueryFragments = append(rawQueryFragments, encoded)
|
||||
}
|
||||
queryURL.RawQuery = strings.Join(rawQueryFragments, "&")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewDeleteDownloaderRequest generates requests for DeleteDownloader
|
||||
func NewDeleteDownloaderRequest(server string, id string) (*http.Request, error) {
|
||||
var err error
|
||||
@@ -13848,18 +14115,6 @@ func NewListDownloadTasksRequest(server string, params *ListDownloadTasksParams)
|
||||
|
||||
}
|
||||
|
||||
if params.AssignedTo != nil {
|
||||
|
||||
if queryFrag, err := runtime.StyleParamWithOptions("form", true, "assignedTo", *params.AssignedTo, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
for _, qp := range strings.Split(queryFrag, "&") {
|
||||
rawQueryFragments = append(rawQueryFragments, qp)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if params.Category != nil {
|
||||
|
||||
if queryFrag, err := runtime.StyleParamWithOptions("form", true, "category", *params.Category, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil {
|
||||
@@ -19607,6 +19862,9 @@ type ClientWithResponsesInterface interface {
|
||||
|
||||
RecordDownloaderHeartbeatWithResponse(ctx context.Context, body RecordDownloaderHeartbeatJSONRequestBody, reqEditors ...RequestEditorFn) (*RecordDownloaderHeartbeatResponse, error)
|
||||
|
||||
// ListDownloaderTasksWithResponse request
|
||||
ListDownloaderTasksWithResponse(ctx context.Context, params *ListDownloaderTasksParams, reqEditors ...RequestEditorFn) (*ListDownloaderTasksResponse, error)
|
||||
|
||||
// DeleteDownloaderWithResponse request
|
||||
DeleteDownloaderWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*DeleteDownloaderResponse, error)
|
||||
|
||||
@@ -24946,6 +25204,38 @@ func (r RecordDownloaderHeartbeatResponse) ContentType() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type ListDownloaderTasksResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON200 *DownloadTaskPage
|
||||
JSON400 *Error
|
||||
JSON401 *Error
|
||||
}
|
||||
|
||||
// Status returns HTTPResponse.Status
|
||||
func (r ListDownloaderTasksResponse) Status() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Status
|
||||
}
|
||||
return http.StatusText(0)
|
||||
}
|
||||
|
||||
// StatusCode returns HTTPResponse.StatusCode
|
||||
func (r ListDownloaderTasksResponse) StatusCode() int {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.StatusCode
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
|
||||
func (r ListDownloaderTasksResponse) ContentType() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Header.Get("Content-Type")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type DeleteDownloaderResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
@@ -25043,7 +25333,7 @@ func (r UpdateDownloaderCreditBillingResponse) ContentType() string {
|
||||
type ListDownloadTasksResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON200 *DownloadTaskPage
|
||||
JSON200 *DownloadTaskListPage
|
||||
JSON400 *Error
|
||||
JSON401 *Error
|
||||
}
|
||||
@@ -30422,6 +30712,15 @@ func (c *ClientWithResponses) RecordDownloaderHeartbeatWithResponse(ctx context.
|
||||
return ParseRecordDownloaderHeartbeatResponse(rsp)
|
||||
}
|
||||
|
||||
// ListDownloaderTasksWithResponse request returning *ListDownloaderTasksResponse
|
||||
func (c *ClientWithResponses) ListDownloaderTasksWithResponse(ctx context.Context, params *ListDownloaderTasksParams, reqEditors ...RequestEditorFn) (*ListDownloaderTasksResponse, error) {
|
||||
rsp, err := c.ListDownloaderTasks(ctx, params, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseListDownloaderTasksResponse(rsp)
|
||||
}
|
||||
|
||||
// DeleteDownloaderWithResponse request returning *DeleteDownloaderResponse
|
||||
func (c *ClientWithResponses) DeleteDownloaderWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*DeleteDownloaderResponse, error) {
|
||||
rsp, err := c.DeleteDownloader(ctx, id, reqEditors...)
|
||||
@@ -39438,6 +39737,46 @@ func ParseRecordDownloaderHeartbeatResponse(rsp *http.Response) (*RecordDownload
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParseListDownloaderTasksResponse parses an HTTP response from a ListDownloaderTasksWithResponse call
|
||||
func ParseListDownloaderTasksResponse(rsp *http.Response) (*ListDownloaderTasksResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
defer func() { _ = rsp.Body.Close() }()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &ListDownloaderTasksResponse{
|
||||
Body: bodyBytes,
|
||||
HTTPResponse: rsp,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
|
||||
var dest DownloadTaskPage
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON200 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
|
||||
var dest Error
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON400 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401:
|
||||
var dest Error
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON401 = &dest
|
||||
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParseDeleteDownloaderResponse parses an HTTP response from a DeleteDownloaderWithResponse call
|
||||
func ParseDeleteDownloaderResponse(rsp *http.Response) (*DeleteDownloaderResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
@@ -39559,7 +39898,7 @@ func ParseListDownloadTasksResponse(rsp *http.Response) (*ListDownloadTasksRespo
|
||||
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
|
||||
var dest DownloadTaskPage
|
||||
var dest DownloadTaskListPage
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -52,10 +52,22 @@ the resource state change. The row contains:
|
||||
- resource type and id;
|
||||
- change type, action, optional metadata, and occurrence time.
|
||||
|
||||
The stream sends `resource-change` invalidation facts, never rendered rows or a
|
||||
replacement page. TanStack Query remains the read authority and refetches the
|
||||
affected collection. This avoids trying to splice a changed record into an
|
||||
arbitrary filtered or partially loaded page.
|
||||
The stream sends `resource-change` facts, never rendered rows or replacement
|
||||
pages. TanStack Query remains the read authority:
|
||||
|
||||
- download-task updates fetch that task through the detail endpoint, update its
|
||||
detail cache, and project the list fields into already-loaded cursor pages;
|
||||
- creates, wildcard changes, retention gaps, and changes that may enter an
|
||||
unloaded filtered result reset the affected collection.
|
||||
|
||||
List items contain only fields needed to render and operate a row. Detail-only
|
||||
arrays such as task files, peers, and trackers are returned only by the
|
||||
single-task detail endpoint. Small detail collections remain ordinary lists;
|
||||
they are not virtualized.
|
||||
|
||||
The downloader agent uses a separate assigned-task endpoint because it needs
|
||||
the full execution contract, including upload credentials. Browser list
|
||||
contracts never expose those fields.
|
||||
|
||||
`Last-Event-ID` resumes after a disconnect. Changes are retained for seven
|
||||
days. If a client asks for a sequence older than retained history, the stream
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { toDownloadTaskListItem } from '@shared/download-task'
|
||||
import { downloadTaskRuntimeSchema } from '@shared/schemas'
|
||||
import type { DownloadTask, DownloadTaskRuntime } from '@shared/types'
|
||||
import {
|
||||
@@ -297,6 +298,32 @@ export function createDownloadTaskRepo(db: Database): DownloadTaskRepo {
|
||||
return rows[0] ?? null
|
||||
}
|
||||
|
||||
async function getRow(orgId: string, id: string): Promise<DownloadTaskRow> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(downloadTasks)
|
||||
.where(and(eq(downloadTasks.id, id), eq(downloadTasks.orgId, orgId), isNull(downloadTasks.deletedAt)))
|
||||
.limit(1)
|
||||
if (!rows[0]) throw new DownloadError('not_found')
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
async function listRows(filters: ListDownloadTasksFilters) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(downloadTasks)
|
||||
.where(downloadTaskWhere(filters))
|
||||
.orderBy(desc(downloadTasks.createdAt), desc(downloadTasks.id))
|
||||
.limit(filters.pageSize + 1)
|
||||
const hasMore = rows.length > filters.pageSize
|
||||
const items = hasMore ? rows.slice(0, filters.pageSize) : rows
|
||||
const last = items.at(-1)
|
||||
return {
|
||||
items,
|
||||
nextBoundary: hasMore && last ? { createdAt: last.createdAt, id: last.id } : null,
|
||||
}
|
||||
}
|
||||
|
||||
async function changeTargets(where: SQL | undefined) {
|
||||
return db.select({ id: downloadTasks.id, orgId: downloadTasks.orgId }).from(downloadTasks).where(where)
|
||||
}
|
||||
@@ -377,30 +404,24 @@ export function createDownloadTaskRepo(db: Database): DownloadTaskRepo {
|
||||
},
|
||||
|
||||
async list(filters: ListDownloadTasksFilters) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(downloadTasks)
|
||||
.where(downloadTaskWhere(filters))
|
||||
.orderBy(desc(downloadTasks.createdAt), desc(downloadTasks.id))
|
||||
.limit(filters.pageSize + 1)
|
||||
const hasMore = rows.length > filters.pageSize
|
||||
const taskRows = hasMore ? rows.slice(0, filters.pageSize) : rows
|
||||
const last = taskRows.at(-1)
|
||||
const page = await listRows(filters)
|
||||
return {
|
||||
items: taskRows.map((row) => toDownloadTask(row)),
|
||||
rows: taskRows.map((row) => toRecord(row)),
|
||||
nextBoundary: hasMore && last ? { createdAt: last.createdAt, id: last.id } : null,
|
||||
items: page.items.map((row) => toDownloadTask(row)),
|
||||
rows: page.items.map((row) => toRecord(row)),
|
||||
nextBoundary: page.nextBoundary,
|
||||
}
|
||||
},
|
||||
|
||||
async listItems(filters: ListDownloadTasksFilters) {
|
||||
const page = await listRows(filters)
|
||||
return {
|
||||
items: page.items.map((row) => toDownloadTaskListItem(toDownloadTask(row))),
|
||||
nextBoundary: page.nextBoundary,
|
||||
}
|
||||
},
|
||||
|
||||
async get(orgId, id) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(downloadTasks)
|
||||
.where(and(eq(downloadTasks.id, id), eq(downloadTasks.orgId, orgId), isNull(downloadTasks.deletedAt)))
|
||||
.limit(1)
|
||||
if (!rows[0]) throw new DownloadError('not_found')
|
||||
return toDownloadTask(rows[0])
|
||||
return toDownloadTask(await getRow(orgId, id))
|
||||
},
|
||||
|
||||
async getRecord(orgId, id) {
|
||||
@@ -570,6 +591,7 @@ export function createDownloadTaskRepo(db: Database): DownloadTaskRepo {
|
||||
.update(downloadTasks)
|
||||
.set({ ...fields, events })
|
||||
.where(and(eq(downloadTasks.id, id), isNull(downloadTasks.deletedAt)))
|
||||
const statusChanged = fields.status !== undefined && fields.status !== row.status
|
||||
await executeWriteTransaction(db, [
|
||||
update,
|
||||
resourceChangeQuery(db, {
|
||||
@@ -578,7 +600,7 @@ export function createDownloadTaskRepo(db: Database): DownloadTaskRepo {
|
||||
resourceType: 'download_task',
|
||||
resourceId: id,
|
||||
changeType: 'upsert',
|
||||
action: fields.status === undefined ? 'updated' : 'status_changed',
|
||||
action: statusChanged ? 'status_changed' : 'updated',
|
||||
occurredAt: now,
|
||||
}),
|
||||
])
|
||||
|
||||
+3
-1
@@ -12,7 +12,7 @@ import { adminStats } from './http/admin-stats'
|
||||
import { serveAvatarBlob } from './http/avatar-blobs'
|
||||
import backgroundJobs from './http/background-jobs'
|
||||
import { configz } from './http/configz'
|
||||
import downloadTasks from './http/downloads/download-tasks'
|
||||
import downloadTasks, { downloaderTasksRoute } from './http/downloads/download-tasks'
|
||||
import downloaders, { downloaderSelfRoute } from './http/downloads/downloaders'
|
||||
import { events } from './http/events'
|
||||
import ihostConfig from './http/image-hosting/config'
|
||||
@@ -267,6 +267,7 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
|
||||
app.route('/api/downloads/tasks', downloadTasks)
|
||||
app.route('/api/events', events)
|
||||
app.route('/api/downloads/downloaders', downloaderSelfRoute)
|
||||
app.route('/api/downloads/downloaders', downloaderTasksRoute)
|
||||
app.route('/api/image-hosting', ihost)
|
||||
app.route('/api/image-hosting/config', ihostConfig)
|
||||
app.route('/api/site/licensing', licensingAdmin)
|
||||
@@ -378,6 +379,7 @@ export type DownloadTasksRoute = typeof downloadTasks
|
||||
export type EventsRoute = typeof events
|
||||
export type DownloadersRoute = typeof downloaders
|
||||
export type DownloaderSelfRoute = typeof downloaderSelfRoute
|
||||
export type DownloaderTasksRoute = typeof downloaderTasksRoute
|
||||
export type IhostRoute = typeof ihost
|
||||
export type IhostConfigRoute = typeof ihostConfig
|
||||
export type AnnouncementsRoute = typeof announcements
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Downloader, DownloadTask, DownloadTaskTimelineItem } from '@shared/types'
|
||||
import type { Downloader, DownloadTask, DownloadTaskListItem, DownloadTaskTimelineItem } from '@shared/types'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { S3Service } from '../../adapters/gateways/s3.js'
|
||||
@@ -302,21 +302,21 @@ describe('Download tasks API integration', () => {
|
||||
expect(task.status.state).toBe('queued')
|
||||
await claimTaskForDownloader(app, createdDownloader.token, task.id)
|
||||
|
||||
const includedRes = await app.request('/api/downloads/tasks?assignedTo=me&status=assigned,downloading', {
|
||||
const includedRes = await app.request('/api/downloads/downloaders/me/tasks?status=assigned,downloading', {
|
||||
headers: downloaderHeaders,
|
||||
})
|
||||
expect(includedRes.status).toBe(200)
|
||||
const included = (await includedRes.json()) as DownloadTaskList
|
||||
expect(included.items.map((item) => item.id)).toContain(task.id)
|
||||
|
||||
const excludedRes = await app.request('/api/downloads/tasks?assignedTo=me&status=downloading,canceling', {
|
||||
const excludedRes = await app.request('/api/downloads/downloaders/me/tasks?status=downloading,canceling', {
|
||||
headers: downloaderHeaders,
|
||||
})
|
||||
expect(excludedRes.status).toBe(200)
|
||||
const excluded = (await excludedRes.json()) as DownloadTaskList
|
||||
expect(excluded.items.map((item) => item.id)).not.toContain(task.id)
|
||||
|
||||
const invalidRes = await app.request('/api/downloads/tasks?assignedTo=me&status=assigned,nope', {
|
||||
const invalidRes = await app.request('/api/downloads/downloaders/me/tasks?status=assigned,nope', {
|
||||
headers: downloaderHeaders,
|
||||
})
|
||||
expect(invalidRes.status).toBe(400)
|
||||
@@ -752,7 +752,7 @@ describe('Download tasks API integration', () => {
|
||||
expect(createdTask.spec.labels.tags).toEqual(['sample', 'http'])
|
||||
expect(createdTask.status.assignment?.uploadToken).toBeUndefined()
|
||||
|
||||
const assignedRes = await app.request('/api/downloads/tasks?assignedTo=me&category=fixtures&tag=http', {
|
||||
const assignedRes = await app.request('/api/downloads/downloaders/me/tasks?category=fixtures&tag=http', {
|
||||
headers: { Authorization: `Bearer ${createdDownloader.token}` },
|
||||
})
|
||||
expect(assignedRes.status).toBe(200)
|
||||
@@ -801,7 +801,7 @@ describe('Download tasks API integration', () => {
|
||||
expect(runtime?.torrent?.infoHash).toBe('abc123')
|
||||
expect(runtime?.trackers?.[0]?.url).toBe('udp://tracker.example/announce')
|
||||
|
||||
const recoverDownloadingRes = await app.request('/api/downloads/tasks?assignedTo=me&status=downloading', {
|
||||
const recoverDownloadingRes = await app.request('/api/downloads/downloaders/me/tasks?status=downloading', {
|
||||
headers: { Authorization: `Bearer ${createdDownloader.token}` },
|
||||
})
|
||||
expect(recoverDownloadingRes.status).toBe(200)
|
||||
@@ -952,7 +952,7 @@ describe('Download tasks API integration', () => {
|
||||
}),
|
||||
})
|
||||
expect(uploadingRes.status).toBe(200)
|
||||
const recoverUploadingRes = await app.request('/api/downloads/tasks?assignedTo=me&status=uploading', {
|
||||
const recoverUploadingRes = await app.request('/api/downloads/downloaders/me/tasks?status=uploading', {
|
||||
headers: { Authorization: `Bearer ${createdDownloader.token}` },
|
||||
})
|
||||
expect(recoverUploadingRes.status).toBe(200)
|
||||
@@ -1196,7 +1196,7 @@ describe('Download tasks API integration', () => {
|
||||
expect(recovered.status.billing?.chargedCredits).toBe(1)
|
||||
})
|
||||
|
||||
it('stores downloader runtime reports as snapshots while progress remains patchable [spec: download-tasks/runtime-reports]', async () => {
|
||||
it('stores runtime snapshots and separates list fields from task detail [spec: download-tasks/runtime-reports] [spec: download-tasks/list-detail]', async () => {
|
||||
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
await insertStorage(db)
|
||||
|
||||
@@ -1290,6 +1290,28 @@ describe('Download tasks API integration', () => {
|
||||
trackers: [{ url: 'udp://tracker.example/announce', status: 'working', seeds: 2 }],
|
||||
})
|
||||
|
||||
const listRes = await app.request('/api/downloads/tasks', { headers: user })
|
||||
expect(listRes.status).toBe(200)
|
||||
const list = (await listRes.json()) as { items: DownloadTaskListItem[] }
|
||||
const listedTask = list.items.find((item) => item.id === task.id)
|
||||
expect(listedTask?.status.runtime).toEqual({
|
||||
phase: 'uploading',
|
||||
etaSeconds: 24,
|
||||
torrent: { infoHash: 'patch-info-hash', name: 'patch-progress' },
|
||||
})
|
||||
expect(listedTask?.status).not.toHaveProperty('assignment')
|
||||
expect(listedTask?.status).not.toHaveProperty('billing')
|
||||
expect(listedTask?.status).not.toHaveProperty('error')
|
||||
|
||||
const detailRes = await app.request(`/api/downloads/tasks/${task.id}`, { headers: user })
|
||||
expect(detailRes.status).toBe(200)
|
||||
const taskDetail = (await detailRes.json()) as DownloadTask
|
||||
expect(taskDetail.status.runtime).toMatchObject({
|
||||
engine: 'aria2',
|
||||
progress: uploadingTask.status.runtime?.progress,
|
||||
trackers: [{ url: 'udp://tracker.example/announce' }],
|
||||
})
|
||||
|
||||
const replacementRuntimeRes = await app.request(`/api/downloads/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: downloaderHeaders,
|
||||
@@ -1308,6 +1330,14 @@ describe('Download tasks API integration', () => {
|
||||
upload: { bytes: 4 * 1024 * 1024, totalBytes, bytesPerSecond: 256_000 },
|
||||
},
|
||||
})
|
||||
const [runtimeChange] = await db.all<{ action: string }>(sql`
|
||||
SELECT action
|
||||
FROM resource_changes
|
||||
WHERE resource_type = 'download_task' AND resource_id = ${task.id}
|
||||
ORDER BY sequence DESC
|
||||
LIMIT 1
|
||||
`)
|
||||
expect(runtimeChange?.action).toBe('updated')
|
||||
|
||||
const completedRes = await app.request(`/api/downloads/tasks/${task.id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -1481,7 +1511,7 @@ describe('Download tasks API integration', () => {
|
||||
const task = (await taskRes.json()) as DownloadTask
|
||||
await claimTaskForDownloader(app, createdDownloader.token, task.id)
|
||||
|
||||
const tasksRes = await app.request('/api/downloads/tasks?assignedTo=me&status=assigned', {
|
||||
const tasksRes = await app.request('/api/downloads/downloaders/me/tasks?status=assigned', {
|
||||
headers: { Authorization: `Bearer ${createdDownloader.token}` },
|
||||
})
|
||||
const tasks = (await tasksRes.json()) as DownloadTaskList
|
||||
@@ -1762,7 +1792,7 @@ describe('Download tasks API integration', () => {
|
||||
expect(pauseRes.status).toBe(200)
|
||||
await expect(pauseRes.json()).resolves.toMatchObject({ status: { state: 'paused' } })
|
||||
|
||||
const pausedAssignedRes = await app.request('/api/downloads/tasks?assignedTo=me', {
|
||||
const pausedAssignedRes = await app.request('/api/downloads/downloaders/me/tasks', {
|
||||
headers: { Authorization: `Bearer ${createdDownloader.token}` },
|
||||
})
|
||||
expect(pausedAssignedRes.status).toBe(200)
|
||||
@@ -1798,7 +1828,7 @@ describe('Download tasks API integration', () => {
|
||||
expect(cancelRes.status).toBe(200)
|
||||
await expect(cancelRes.json()).resolves.toMatchObject({ status: { state: 'canceling' } })
|
||||
|
||||
const canceledAssignedRes = await app.request('/api/downloads/tasks?assignedTo=me', {
|
||||
const canceledAssignedRes = await app.request('/api/downloads/downloaders/me/tasks', {
|
||||
headers: { Authorization: `Bearer ${createdDownloader.token}` },
|
||||
})
|
||||
expect(canceledAssignedRes.status).toBe(200)
|
||||
@@ -1949,7 +1979,7 @@ describe('Download tasks API integration', () => {
|
||||
},
|
||||
})
|
||||
|
||||
const interruptedAssignedRes = await app.request('/api/downloads/tasks?assignedTo=me&status=interrupted', {
|
||||
const interruptedAssignedRes = await app.request('/api/downloads/downloaders/me/tasks?status=interrupted', {
|
||||
headers: { Authorization: `Bearer ${createdDownloader.token}` },
|
||||
})
|
||||
expect(interruptedAssignedRes.status).toBe(200)
|
||||
@@ -2078,7 +2108,7 @@ describe('Download tasks API integration', () => {
|
||||
expect(retriedTask.status.runtime?.message).toBeUndefined()
|
||||
await claimTaskForDownloader(app, createdDownloader.token, createdTask.id)
|
||||
|
||||
const assignedRes = await app.request('/api/downloads/tasks?assignedTo=me&status=assigned', {
|
||||
const assignedRes = await app.request('/api/downloads/downloaders/me/tasks?status=assigned', {
|
||||
headers: { Authorization: `Bearer ${createdDownloader.token}` },
|
||||
})
|
||||
expect(assignedRes.status).toBe(200)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import {
|
||||
createDownloadTaskSchema,
|
||||
downloadTaskAttemptSchema,
|
||||
downloadTaskListPageSchema,
|
||||
downloadTaskPageSchema,
|
||||
downloadTaskSchema,
|
||||
downloadTaskStatusUpdateSchema,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
createDownloadTask,
|
||||
getDownloadTask,
|
||||
getDownloadTaskTimeline,
|
||||
listDownloadTaskItems,
|
||||
listDownloadTasks,
|
||||
performDownloadTaskAction,
|
||||
updateDownloadTask,
|
||||
@@ -43,7 +45,7 @@ const downloadTaskStatuses = new Set([
|
||||
'canceled',
|
||||
])
|
||||
|
||||
async function listPage(
|
||||
async function resolvePage(
|
||||
c: {
|
||||
get(name: 'deps'): Env['Variables']['deps']
|
||||
get(name: 'platform'): Env['Variables']['platform']
|
||||
@@ -65,14 +67,18 @@ async function listPage(
|
||||
query,
|
||||
codec: createdAtIdCursorCodec,
|
||||
})
|
||||
const result = await listDownloadTasks(c.get('deps'), c.get('platform'), { ...filters, after })
|
||||
return {
|
||||
items: result.items,
|
||||
nextPageToken: await encodeNextPageToken(c.get('platform'), result.nextBoundary, {
|
||||
query,
|
||||
codec: createdAtIdCursorCodec,
|
||||
}),
|
||||
}
|
||||
return { filters: { ...filters, after }, query }
|
||||
}
|
||||
|
||||
async function pageTokenFor(
|
||||
c: { get(name: 'platform'): Env['Variables']['platform'] },
|
||||
nextBoundary: { createdAt: Date; id: string } | null,
|
||||
query: string,
|
||||
) {
|
||||
return encodeNextPageToken(c.get('platform'), nextBoundary, {
|
||||
query,
|
||||
codec: createdAtIdCursorCodec,
|
||||
})
|
||||
}
|
||||
|
||||
function parseStatuses(value: string | undefined): string[] | undefined {
|
||||
@@ -104,10 +110,25 @@ const listRoute = createRoute({
|
||||
tags: ['Download Tasks'],
|
||||
method: 'get',
|
||||
path: '/',
|
||||
middleware: [requirePermission('remoteDownload', 'read')] as const,
|
||||
request: { query: listDownloadTasksQuerySchema },
|
||||
responses: {
|
||||
200: jsonContent(downloadTaskListPageSchema, 'Download task list'),
|
||||
400: errorResponse('Invalid query'),
|
||||
401: errorResponse('Unauthorized'),
|
||||
},
|
||||
})
|
||||
|
||||
const downloaderTaskListRoute = createRoute({
|
||||
operationId: 'listDownloaderTasks',
|
||||
summary: 'List tasks owned by the authenticated downloader',
|
||||
tags: ['Downloaders'],
|
||||
method: 'get',
|
||||
path: '/me/tasks',
|
||||
middleware: [requirePermission('remoteDownload', 'read', { allowDownloader: true })] as const,
|
||||
request: { query: listDownloadTasksQuerySchema },
|
||||
responses: {
|
||||
200: jsonContent(downloadTaskPageSchema, 'Download tasks'),
|
||||
200: jsonContent(downloadTaskPageSchema, 'Assigned download tasks'),
|
||||
400: errorResponse('Invalid query'),
|
||||
401: errorResponse('Unauthorized'),
|
||||
},
|
||||
@@ -213,44 +234,28 @@ const deleteRoute = createRoute({
|
||||
|
||||
const downloadTasksRoute = new OpenAPIHono<Env>()
|
||||
.openapi(listRoute, async (c) => {
|
||||
const principal = c.get('principal')
|
||||
const query = c.req.valid('query')
|
||||
const statuses = parseStatuses(query.status)
|
||||
if (query.assignedTo === 'me') {
|
||||
if (principal?.kind !== 'downloader') throw unauthorized()
|
||||
return c.json(
|
||||
await listPage(
|
||||
c,
|
||||
{
|
||||
downloaderId: principal.downloaderId,
|
||||
status: statuses?.length === 1 ? statuses[0] : undefined,
|
||||
statuses,
|
||||
category: query.category,
|
||||
tag: query.tag,
|
||||
pageSize: query.pageSize,
|
||||
includeUploadToken: true,
|
||||
},
|
||||
query.pageToken,
|
||||
),
|
||||
200,
|
||||
)
|
||||
}
|
||||
|
||||
const orgId = c.get('orgId')
|
||||
if (!orgId) throw unauthorized()
|
||||
const page = await resolvePage(
|
||||
c,
|
||||
{
|
||||
orgId,
|
||||
status: statuses?.length === 1 ? statuses[0] : undefined,
|
||||
statuses,
|
||||
category: query.category,
|
||||
tag: query.tag,
|
||||
pageSize: query.pageSize,
|
||||
},
|
||||
query.pageToken,
|
||||
)
|
||||
const result = await listDownloadTaskItems(c.get('deps'), page.filters)
|
||||
return c.json(
|
||||
await listPage(
|
||||
c,
|
||||
{
|
||||
orgId,
|
||||
status: statuses?.length === 1 ? statuses[0] : undefined,
|
||||
statuses,
|
||||
category: query.category,
|
||||
tag: query.tag,
|
||||
pageSize: query.pageSize,
|
||||
},
|
||||
query.pageToken,
|
||||
),
|
||||
{
|
||||
items: result.items,
|
||||
nextPageToken: await pageTokenFor(c, result.nextBoundary, page.query),
|
||||
},
|
||||
200,
|
||||
)
|
||||
})
|
||||
@@ -308,4 +313,32 @@ const downloadTasksRoute = new OpenAPIHono<Env>()
|
||||
return c.json(await updateDownloadTask(c.get('deps'), c.get('platform'), id, input, { orgId }), 200)
|
||||
})
|
||||
|
||||
export const downloaderTasksRoute = new OpenAPIHono<Env>().openapi(downloaderTaskListRoute, async (c) => {
|
||||
const principal = c.get('principal')
|
||||
if (principal?.kind !== 'downloader') throw unauthorized()
|
||||
const query = c.req.valid('query')
|
||||
const statuses = parseStatuses(query.status)
|
||||
const page = await resolvePage(
|
||||
c,
|
||||
{
|
||||
downloaderId: principal.downloaderId,
|
||||
status: statuses?.length === 1 ? statuses[0] : undefined,
|
||||
statuses,
|
||||
category: query.category,
|
||||
tag: query.tag,
|
||||
pageSize: query.pageSize,
|
||||
includeUploadToken: true,
|
||||
},
|
||||
query.pageToken,
|
||||
)
|
||||
const result = await listDownloadTasks(c.get('deps'), c.get('platform'), page.filters)
|
||||
return c.json(
|
||||
{
|
||||
items: result.items,
|
||||
nextPageToken: await pageTokenFor(c, result.nextBoundary, page.query),
|
||||
},
|
||||
200,
|
||||
)
|
||||
})
|
||||
|
||||
export default downloadTasksRoute
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
Downloader,
|
||||
DownloadTask,
|
||||
DownloadTaskEvent,
|
||||
DownloadTaskListItem,
|
||||
DownloadTaskRuntime,
|
||||
DownloadTaskTimelineItem,
|
||||
} from '@shared/types'
|
||||
@@ -317,6 +318,16 @@ export async function listDownloadTasks(
|
||||
return { items: decorated, nextBoundary }
|
||||
}
|
||||
|
||||
export async function listDownloadTaskItems(
|
||||
deps: DownloadsDeps,
|
||||
opts: ListDownloadTasksFilters,
|
||||
): Promise<{
|
||||
items: DownloadTaskListItem[]
|
||||
nextBoundary: { createdAt: Date; id: string } | null
|
||||
}> {
|
||||
return deps.downloadTasks.listItems(opts)
|
||||
}
|
||||
|
||||
export function getDownloadTask(deps: DownloadsDeps, orgId: string, id: string): Promise<DownloadTask> {
|
||||
return deps.downloadTasks.get(orgId, id)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Downloader, DownloadTask } from '@shared/types'
|
||||
import type { Downloader, DownloadTask, DownloadTaskListItem } from '@shared/types'
|
||||
|
||||
// ─── Errors ──────────────────────────────────────────────────────────────────
|
||||
// Thrown by the repos (not_found/forbidden) and the orchestration state machine
|
||||
@@ -196,6 +196,10 @@ export interface DownloadTaskRepo {
|
||||
rows: DownloadTaskRecord[]
|
||||
nextBoundary: { createdAt: Date; id: string } | null
|
||||
}>
|
||||
listItems(filters: ListDownloadTasksFilters): Promise<{
|
||||
items: DownloadTaskListItem[]
|
||||
nextBoundary: { createdAt: Date; id: string } | null
|
||||
}>
|
||||
/** API DTO scoped to org; throws DownloadError('not_found') when missing. */
|
||||
get(orgId: string, id: string): Promise<DownloadTask>
|
||||
/** Raw record scoped to org; throws DownloadError('not_found') when missing. */
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toDownloadTaskListItem } from './download-task'
|
||||
import type { DownloadTask } from './schemas/downloads'
|
||||
|
||||
describe('toDownloadTaskListItem', () => {
|
||||
it('keeps list fields and strips detail-only runtime data without mutating the task', () => {
|
||||
const runtime = {
|
||||
engine: 'aria2' as const,
|
||||
state: 'active',
|
||||
phase: 'downloading' as const,
|
||||
message: 'downloading',
|
||||
etaSeconds: 42,
|
||||
torrent: { infoHash: 'hash', name: 'archive', peers: 3 },
|
||||
trackers: [{ url: 'udp://tracker.example', status: 'working' }],
|
||||
peers: [{ address: '127.0.0.1:6881', client: 'test' }],
|
||||
files: [{ path: 'archive/file.txt', size: 10 }],
|
||||
}
|
||||
const task = {
|
||||
id: 'task-1',
|
||||
status: { runtime },
|
||||
} as unknown as DownloadTask
|
||||
|
||||
const item = toDownloadTaskListItem(task)
|
||||
|
||||
expect(item.id).toBe('task-1')
|
||||
expect(item.status.runtime).toEqual({
|
||||
phase: 'downloading',
|
||||
etaSeconds: 42,
|
||||
torrent: { infoHash: 'hash', name: 'archive', peers: 3 },
|
||||
})
|
||||
expect(task.status.runtime).toBe(runtime)
|
||||
expect(task.status.runtime?.trackers).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves a null runtime', () => {
|
||||
const task = {
|
||||
id: 'task-1',
|
||||
status: { runtime: null },
|
||||
} as unknown as DownloadTask
|
||||
|
||||
expect(toDownloadTaskListItem(task).status.runtime).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { DownloadTask, DownloadTaskListItem } from './schemas/downloads'
|
||||
|
||||
export function toDownloadTaskListItem(task: DownloadTask): DownloadTaskListItem {
|
||||
const runtime = task.status.runtime
|
||||
return {
|
||||
id: task.id,
|
||||
spec: task.spec,
|
||||
status: {
|
||||
state: task.status.state,
|
||||
progress: task.status.progress,
|
||||
runtime: runtime
|
||||
? {
|
||||
phase: runtime.phase,
|
||||
etaSeconds: runtime.etaSeconds,
|
||||
torrent: runtime.torrent,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
createdAt: task.createdAt,
|
||||
}
|
||||
}
|
||||
@@ -187,6 +187,27 @@ export const downloadTaskSchema = z
|
||||
|
||||
export type DownloadTask = z.infer<typeof downloadTaskSchema>
|
||||
|
||||
export const downloadTaskListItemRuntimeSchema = downloadTaskRuntimeSchema.pick({
|
||||
phase: true,
|
||||
etaSeconds: true,
|
||||
torrent: true,
|
||||
})
|
||||
|
||||
export const downloadTaskListItemSchema = z
|
||||
.object({
|
||||
id: downloadTaskSchema.shape.id,
|
||||
spec: downloadTaskSchema.shape.spec,
|
||||
status: z.object({
|
||||
state: downloadTaskStatusSchema,
|
||||
progress: downloadTaskProgressSchema,
|
||||
runtime: downloadTaskListItemRuntimeSchema.nullable(),
|
||||
}),
|
||||
createdAt: downloadTaskSchema.shape.createdAt,
|
||||
})
|
||||
.openapi('DownloadTaskListItem')
|
||||
|
||||
export type DownloadTaskListItem = z.infer<typeof downloadTaskListItemSchema>
|
||||
|
||||
export const downloadTaskTimelineItemSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -218,6 +239,13 @@ export const downloadTaskPageSchema = z
|
||||
})
|
||||
.openapi('DownloadTaskPage')
|
||||
|
||||
export const downloadTaskListPageSchema = z
|
||||
.object({
|
||||
items: z.array(downloadTaskListItemSchema),
|
||||
nextPageToken: z.string().nullable(),
|
||||
})
|
||||
.openapi('DownloadTaskListPage')
|
||||
|
||||
export const downloaderHeartbeatSchema = z.object({
|
||||
version: z.string().min(1).max(80),
|
||||
hostname: z.string().min(1).max(160),
|
||||
@@ -390,7 +418,6 @@ export const downloadTaskAttemptSchema = z.object({
|
||||
|
||||
export const listDownloadTasksQuerySchema = z.object({
|
||||
status: z.string().optional(),
|
||||
assignedTo: z.enum(['me']).optional(),
|
||||
category: z.string().trim().min(1).max(120).optional(),
|
||||
tag: z.string().trim().min(1).max(80).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
|
||||
@@ -81,6 +81,7 @@ export type {
|
||||
DownloaderHeartbeatResult,
|
||||
DownloadTaskActionInput,
|
||||
DownloadTaskEvent,
|
||||
DownloadTaskListItem,
|
||||
DownloadTaskRuntime,
|
||||
DownloadTaskSchema,
|
||||
ListDownloadTasksQuery,
|
||||
@@ -106,6 +107,9 @@ export {
|
||||
downloadTaskActionSchema,
|
||||
downloadTaskAttemptSchema,
|
||||
downloadTaskEventSchema,
|
||||
downloadTaskListItemRuntimeSchema,
|
||||
downloadTaskListItemSchema,
|
||||
downloadTaskListPageSchema,
|
||||
downloadTaskPageSchema,
|
||||
downloadTaskRuntimeSchema,
|
||||
downloadTaskSchema,
|
||||
|
||||
@@ -273,6 +273,7 @@ export type DownloadTaskBillingState = 'none' | 'ok' | 'insufficient_credits'
|
||||
export type {
|
||||
DownloadTask,
|
||||
DownloadTaskEvent,
|
||||
DownloadTaskListItem,
|
||||
DownloadTaskTimeline,
|
||||
DownloadTaskTimelineItem,
|
||||
} from '../schemas/downloads'
|
||||
|
||||
@@ -99,6 +99,12 @@ Feature: Remote download tasks
|
||||
When it reports runtime state
|
||||
Then reports are stored as snapshots and progress remains patchable
|
||||
|
||||
@download-tasks/list-detail @api
|
||||
Scenario: Browser lists return only list fields
|
||||
Given a task with runtime files, peers, and trackers
|
||||
When a user lists tasks and then opens one task
|
||||
Then the list returns only its required fields and the detail returns the full runtime
|
||||
|
||||
@download-tasks/events @api
|
||||
Scenario: Task events expose lifecycle and audit history
|
||||
Given a task with lifecycle timestamps and audit events
|
||||
|
||||
+3
-3
@@ -11,14 +11,14 @@ Feature: Event stream
|
||||
@events/stream @api
|
||||
Scenario: A browser session receives its multiplexed event stream
|
||||
Given an authenticated user
|
||||
When they open the event stream with download tasks enabled
|
||||
When they open the global event stream
|
||||
Then job, notification, and download-task events are streamed to them
|
||||
|
||||
@events/api-key-download-tasks @api
|
||||
Scenario: An authorized organization API key receives only its organization's download tasks
|
||||
Given an organization API key with remoteDownload read permission
|
||||
And download tasks exist in its organization and another organization
|
||||
When it opens the event stream with download tasks enabled
|
||||
When it opens the global event stream
|
||||
Then its organization receives a download-tasks event
|
||||
And the stream contains no notification, job, or other-organization task data
|
||||
|
||||
@@ -37,7 +37,7 @@ Feature: Event stream
|
||||
@events/api-key-invalid @api
|
||||
Scenario: An invalid API key cannot open the event stream
|
||||
Given an invalid API key
|
||||
When it opens the event stream with download tasks enabled
|
||||
When it opens the global event stream
|
||||
Then the API responds 401
|
||||
|
||||
@events/abort @api
|
||||
|
||||
+17
-8
@@ -51,6 +51,7 @@ import {
|
||||
getBackgroundJob,
|
||||
getChangelog,
|
||||
getCloudCredits,
|
||||
getDownloadTask,
|
||||
getEmailConfig,
|
||||
getIhostConfig,
|
||||
getInstanceInfo,
|
||||
@@ -1133,7 +1134,6 @@ describe('api', () => {
|
||||
|
||||
const result = await listDownloadTasks({
|
||||
status: 'downloading',
|
||||
assignedTo: 'me',
|
||||
category: 'movies',
|
||||
tag: '4k',
|
||||
pageSize: 10,
|
||||
@@ -1144,7 +1144,6 @@ describe('api', () => {
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/downloads/tasks?')
|
||||
expect(url).toContain('status=downloading')
|
||||
expect(url).toContain('assignedTo=me')
|
||||
expect(url).toContain('category=movies')
|
||||
expect(url).toContain('tag=4k')
|
||||
expect(url).toContain('pageSize=10')
|
||||
@@ -1152,6 +1151,22 @@ describe('api', () => {
|
||||
expect(init.method).toBe('GET')
|
||||
})
|
||||
|
||||
it('gets a download task', async () => {
|
||||
const payload = { id: 'task-1', status: { runtime: { files: [] } } }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
await expect(getDownloadTask('task-1')).resolves.toEqual(payload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/api/downloads/tasks/task-1')
|
||||
expect(init.method).toBe('GET')
|
||||
})
|
||||
|
||||
it('throws when getting a download task fails', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
|
||||
|
||||
await expect(getDownloadTask('missing')).rejects.toThrow('not found')
|
||||
})
|
||||
|
||||
it('creates a download task', async () => {
|
||||
const payload = { id: 'task-1', status: 'queued' }
|
||||
const body = {
|
||||
@@ -1278,12 +1293,6 @@ describe('api', () => {
|
||||
|
||||
it('builds the unified server events URL from RPC client', () => {
|
||||
expect(serverEventsUrl().pathname).toBe('/api/events')
|
||||
|
||||
const url = serverEventsUrl({ downloadTasks: '1', dtStatus: 'downloading', dtSortDir: 'desc' })
|
||||
expect(url.pathname).toBe('/api/events')
|
||||
expect(url.searchParams.get('downloadTasks')).toBe('1')
|
||||
expect(url.searchParams.get('dtStatus')).toBe('downloading')
|
||||
expect(url.searchParams.get('dtSortDir')).toBe('desc')
|
||||
})
|
||||
|
||||
it('lists admin downloaders', async () => {
|
||||
|
||||
+9
-10
@@ -63,6 +63,7 @@ import type {
|
||||
CursorPage,
|
||||
Downloader,
|
||||
DownloadTask,
|
||||
DownloadTaskListItem,
|
||||
DownloadTaskTimeline,
|
||||
IhostConfigResponse,
|
||||
ImageHosting,
|
||||
@@ -400,7 +401,6 @@ export function purgeTrashObject(id: string) {
|
||||
|
||||
export interface ListDownloadTasksOptions {
|
||||
status?: string
|
||||
assignedTo?: 'me'
|
||||
category?: string
|
||||
tag?: string
|
||||
pageSize?: number
|
||||
@@ -412,11 +412,14 @@ export function listDownloadTasks(opts: ListDownloadTasksOptions = {}) {
|
||||
pageSize: String(opts.pageSize ?? 50),
|
||||
}
|
||||
if (opts.status) query.status = opts.status
|
||||
if (opts.assignedTo) query.assignedTo = opts.assignedTo
|
||||
if (opts.category) query.category = opts.category
|
||||
if (opts.tag) query.tag = opts.tag
|
||||
if (opts.pageToken) query.pageToken = opts.pageToken
|
||||
return unwrap<CursorPage<DownloadTask>>(downloadTasksApi.index.$get({ query }))
|
||||
return unwrap<CursorPage<DownloadTaskListItem>>(downloadTasksApi.index.$get({ query }))
|
||||
}
|
||||
|
||||
export function getDownloadTask(id: string) {
|
||||
return unwrap<DownloadTask>(downloadTasksApi[':id'].$get({ param: { id } }))
|
||||
}
|
||||
|
||||
export function createDownloadTask(data: CreateDownloadTaskInput) {
|
||||
@@ -445,13 +448,9 @@ export function runDownloadTaskAction(id: string, action: DownloadTaskActionInpu
|
||||
return unwrap<DownloadTask>(downloadTasksApi[':id'].status.$put({ param: { id }, json: { status } }))
|
||||
}
|
||||
|
||||
// Unified server-sent events stream (background jobs, notifications, and the
|
||||
// opt-in download-tasks domain). Consumed by a raw EventSource in useServerEvents,
|
||||
// so this only builds the URL; `query` carries the active page subscriptions.
|
||||
export function serverEventsUrl(query: Record<string, string> = {}) {
|
||||
const url = eventsUrlApi.index.$url()
|
||||
for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value)
|
||||
return url
|
||||
// Unified server-sent events stream consumed by the authenticated application shell.
|
||||
export function serverEventsUrl() {
|
||||
return eventsUrlApi.index.$url()
|
||||
}
|
||||
|
||||
export function listDownloaders() {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { CursorPage, DownloadTask, DownloadTaskListItem } from '@shared/types'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDownloadTask } from '@/lib/api'
|
||||
import {
|
||||
DOWNLOAD_TASKS_QUERY_KEY,
|
||||
matchesDownloadTaskQuery,
|
||||
removeDownloadTaskFromPages,
|
||||
syncDownloadTaskChange,
|
||||
updateDownloadTaskPages,
|
||||
} from './download-task-cache'
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
getDownloadTask: vi.fn(),
|
||||
}))
|
||||
|
||||
function summary(
|
||||
id: string,
|
||||
state: DownloadTaskListItem['status']['state'] = 'downloading',
|
||||
category: string | null = 'movies',
|
||||
tags = ['linux'],
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
spec: {
|
||||
source: { type: 'http', uri: `https://example.com/${id}` },
|
||||
destination: { folder: 'Downloads', name: `${id}.bin` },
|
||||
labels: { category, tags },
|
||||
},
|
||||
status: {
|
||||
state,
|
||||
progress: {
|
||||
download: { bytes: 10, totalBytes: 100, bytesPerSecond: 2 },
|
||||
upload: { bytes: 0, totalBytes: 100, bytesPerSecond: 0 },
|
||||
},
|
||||
runtime: null,
|
||||
},
|
||||
createdAt: '2026-07-27T00:00:00.000Z',
|
||||
} satisfies DownloadTaskListItem
|
||||
}
|
||||
|
||||
function detail(
|
||||
id: string,
|
||||
state: DownloadTaskListItem['status']['state'] = 'downloading',
|
||||
category: string | null = 'movies',
|
||||
tags = ['linux'],
|
||||
) {
|
||||
const item = summary(id, state, category, tags)
|
||||
return {
|
||||
...item,
|
||||
status: {
|
||||
...item.status,
|
||||
attempt: 1,
|
||||
assignment: null,
|
||||
billing: { state: 'none', authorizedBytes: 0, chargedBytes: 0, chargedCredits: 0 },
|
||||
output: null,
|
||||
error: null,
|
||||
resolveStartedAt: null,
|
||||
resolveCompletedAt: null,
|
||||
downloadCompletedAt: null,
|
||||
ingestStartedAt: null,
|
||||
ingestCompletedAt: null,
|
||||
seedingStartedAt: null,
|
||||
seedingStoppedAt: null,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
updatedAt: '2026-07-27T00:00:00.000Z',
|
||||
},
|
||||
} as DownloadTask
|
||||
}
|
||||
|
||||
function pages(...items: DownloadTaskListItem[]) {
|
||||
return {
|
||||
pages: [{ items, nextPageToken: null }] satisfies CursorPage<DownloadTaskListItem>[],
|
||||
pageParams: [undefined],
|
||||
}
|
||||
}
|
||||
|
||||
describe('download task cache synchronization', () => {
|
||||
let queryClient: QueryClient
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
})
|
||||
|
||||
it('matches all supported list filters', () => {
|
||||
const task = summary('task-1')
|
||||
|
||||
expect(matchesDownloadTaskQuery(task, [...DOWNLOAD_TASKS_QUERY_KEY, 'downloading', 'movies', 'linux'])).toBe(true)
|
||||
expect(matchesDownloadTaskQuery(task, [...DOWNLOAD_TASKS_QUERY_KEY, 'completed', 'movies', 'linux'])).toBe(false)
|
||||
expect(matchesDownloadTaskQuery(task, [...DOWNLOAD_TASKS_QUERY_KEY, 'downloading', 'shows', 'linux'])).toBe(false)
|
||||
expect(matchesDownloadTaskQuery(task, [...DOWNLOAD_TASKS_QUERY_KEY, 'downloading', 'movies', 'bsd'])).toBe(false)
|
||||
})
|
||||
|
||||
it('patches an existing task and removes it when it leaves a filtered result', () => {
|
||||
const original = pages(summary('task-1'))
|
||||
const updated = summary('task-1', 'completed')
|
||||
|
||||
expect(updateDownloadTaskPages(original, updated, DOWNLOAD_TASKS_QUERY_KEY)?.pages[0]?.items).toEqual([updated])
|
||||
expect(
|
||||
updateDownloadTaskPages(original, updated, [...DOWNLOAD_TASKS_QUERY_KEY, 'downloading'])?.pages[0]?.items,
|
||||
).toEqual([])
|
||||
expect(removeDownloadTaskFromPages(original, 'task-1')?.pages[0]?.items).toEqual([])
|
||||
})
|
||||
|
||||
it('updates only the changed list row and its detail cache from one detail request', async () => {
|
||||
const listKey = [...DOWNLOAD_TASKS_QUERY_KEY, '', '', '']
|
||||
const detailKey = ['download-task', 'task-1']
|
||||
queryClient.setQueryData(listKey, pages(summary('task-1'), summary('task-2')))
|
||||
queryClient.setQueryData(detailKey, { id: 'task-1' })
|
||||
const updatedDetail = detail('task-1', 'uploading')
|
||||
const updated = summary('task-1', 'uploading')
|
||||
vi.mocked(getDownloadTask).mockResolvedValue(updatedDetail)
|
||||
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
const reset = vi.spyOn(queryClient, 'resetQueries')
|
||||
|
||||
await syncDownloadTaskChange(queryClient, {
|
||||
resourceType: 'download_task',
|
||||
resourceId: 'task-1',
|
||||
changeType: 'upsert',
|
||||
action: 'updated',
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryData<ReturnType<typeof pages>>(listKey)?.pages[0]?.items).toEqual([
|
||||
updated,
|
||||
summary('task-2'),
|
||||
])
|
||||
expect(reset).not.toHaveBeenCalled()
|
||||
expect(getDownloadTask).toHaveBeenCalledWith('task-1')
|
||||
expect(queryClient.getQueryData(detailKey)).toEqual(updatedDetail)
|
||||
expect(invalidate).not.toHaveBeenCalledWith({
|
||||
queryKey: ['download-task-events', 'task-1'],
|
||||
exact: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('removes deleted task data from every related cache', async () => {
|
||||
const listKey = [...DOWNLOAD_TASKS_QUERY_KEY, '', '', '']
|
||||
queryClient.setQueryData(listKey, pages(summary('task-1'), summary('task-2')))
|
||||
queryClient.setQueryData(['download-task', 'task-1'], { id: 'task-1' })
|
||||
queryClient.setQueryData(['download-task-events', 'task-1'], { items: [] })
|
||||
|
||||
await syncDownloadTaskChange(queryClient, {
|
||||
resourceType: 'download_task',
|
||||
resourceId: 'task-1',
|
||||
changeType: 'delete',
|
||||
action: 'deleted',
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryData<ReturnType<typeof pages>>(listKey)?.pages[0]?.items).toEqual([summary('task-2')])
|
||||
expect(queryClient.getQueryData(['download-task', 'task-1'])).toBeUndefined()
|
||||
expect(queryClient.getQueryData(['download-task-events', 'task-1'])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats an upsert whose authoritative detail is gone as a deletion', async () => {
|
||||
const listKey = [...DOWNLOAD_TASKS_QUERY_KEY, '', '', '']
|
||||
queryClient.setQueryData(listKey, pages(summary('task-1')))
|
||||
vi.mocked(getDownloadTask).mockRejectedValue(Object.assign(new Error('not found'), { status: 404 }))
|
||||
|
||||
await syncDownloadTaskChange(queryClient, {
|
||||
resourceType: 'download_task',
|
||||
resourceId: 'task-1',
|
||||
changeType: 'upsert',
|
||||
action: 'updated',
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryData<ReturnType<typeof pages>>(listKey)?.pages[0]?.items).toEqual([])
|
||||
})
|
||||
|
||||
it('resets a matching status query when a changed task enters the filter', async () => {
|
||||
const filteredKey = [...DOWNLOAD_TASKS_QUERY_KEY, 'downloading', '', '']
|
||||
queryClient.setQueryData(filteredKey, pages(summary('task-2')))
|
||||
vi.mocked(getDownloadTask).mockResolvedValue(detail('task-1'))
|
||||
const reset = vi.spyOn(queryClient, 'resetQueries')
|
||||
|
||||
await syncDownloadTaskChange(queryClient, {
|
||||
resourceType: 'download_task',
|
||||
resourceId: 'task-1',
|
||||
changeType: 'upsert',
|
||||
action: 'status_changed',
|
||||
})
|
||||
|
||||
expect(reset).toHaveBeenCalledWith({ queryKey: filteredKey, exact: true })
|
||||
})
|
||||
|
||||
it('does not reset a filtered page for a routine update to an unloaded task', async () => {
|
||||
const filteredKey = [...DOWNLOAD_TASKS_QUERY_KEY, '', 'movies', '']
|
||||
queryClient.setQueryData(filteredKey, pages(summary('task-2')))
|
||||
vi.mocked(getDownloadTask).mockResolvedValue(detail('task-1'))
|
||||
const reset = vi.spyOn(queryClient, 'resetQueries')
|
||||
|
||||
await syncDownloadTaskChange(queryClient, {
|
||||
resourceType: 'download_task',
|
||||
resourceId: 'task-1',
|
||||
changeType: 'upsert',
|
||||
action: 'updated',
|
||||
})
|
||||
|
||||
expect(reset).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resets matching lists when a task is created', async () => {
|
||||
const listKey = [...DOWNLOAD_TASKS_QUERY_KEY, '', '', '']
|
||||
queryClient.setQueryData(listKey, pages(summary('task-2')))
|
||||
vi.mocked(getDownloadTask).mockResolvedValue(detail('task-1'))
|
||||
const reset = vi.spyOn(queryClient, 'resetQueries')
|
||||
|
||||
await syncDownloadTaskChange(queryClient, {
|
||||
resourceType: 'download_task',
|
||||
resourceId: 'task-1',
|
||||
changeType: 'upsert',
|
||||
action: 'created',
|
||||
})
|
||||
|
||||
expect(reset).toHaveBeenCalledWith({ queryKey: listKey, exact: true })
|
||||
})
|
||||
|
||||
it('resets all task lists for a wildcard change', async () => {
|
||||
const reset = vi.spyOn(queryClient, 'resetQueries')
|
||||
|
||||
await syncDownloadTaskChange(queryClient, {
|
||||
resourceType: 'download_task',
|
||||
resourceId: '*',
|
||||
changeType: 'upsert',
|
||||
action: null,
|
||||
})
|
||||
|
||||
expect(reset).toHaveBeenCalledWith({ queryKey: DOWNLOAD_TASKS_QUERY_KEY })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
import { toDownloadTaskListItem } from '@shared/download-task'
|
||||
import type { CursorPage, DownloadTaskListItem } from '@shared/types'
|
||||
import type { InfiniteData, QueryClient } from '@tanstack/react-query'
|
||||
import { getDownloadTask } from '@/lib/api'
|
||||
|
||||
export const DOWNLOAD_TASKS_QUERY_KEY = ['download-tasks'] as const
|
||||
|
||||
export type DownloadTaskChange = {
|
||||
resourceType: 'download_task'
|
||||
resourceId: string
|
||||
changeType: 'upsert' | 'delete'
|
||||
action: string | null
|
||||
}
|
||||
|
||||
export type DownloadTaskPages = InfiniteData<CursorPage<DownloadTaskListItem>, string | undefined>
|
||||
|
||||
export function matchesDownloadTaskQuery(item: DownloadTaskListItem, queryKey: readonly unknown[]) {
|
||||
const status = typeof queryKey[1] === 'string' ? queryKey[1] : ''
|
||||
const category = typeof queryKey[2] === 'string' ? queryKey[2] : ''
|
||||
const tag = typeof queryKey[3] === 'string' ? queryKey[3] : ''
|
||||
return (
|
||||
(!status || item.status.state === status) &&
|
||||
(!category || item.spec.labels.category === category) &&
|
||||
(!tag || item.spec.labels.tags.includes(tag))
|
||||
)
|
||||
}
|
||||
|
||||
export function updateDownloadTaskPages(
|
||||
data: DownloadTaskPages | undefined,
|
||||
item: DownloadTaskListItem,
|
||||
queryKey: readonly unknown[],
|
||||
) {
|
||||
if (!data) return data
|
||||
const containsTask = data.pages.some((page) => page.items.some((task) => task.id === item.id))
|
||||
if (!containsTask) return data
|
||||
const matches = matchesDownloadTaskQuery(item, queryKey)
|
||||
return {
|
||||
...data,
|
||||
pages: data.pages.map((page) => ({
|
||||
...page,
|
||||
items: matches
|
||||
? page.items.map((task) => (task.id === item.id ? item : task))
|
||||
: page.items.filter((task) => task.id !== item.id),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function removeDownloadTaskFromPages(data: DownloadTaskPages | undefined, taskId: string) {
|
||||
if (!data) return data
|
||||
return {
|
||||
...data,
|
||||
pages: data.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((item) => item.id !== taskId),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function removeDownloadTaskCaches(queryClient: QueryClient, taskId: string) {
|
||||
for (const query of queryClient.getQueryCache().findAll({ queryKey: DOWNLOAD_TASKS_QUERY_KEY })) {
|
||||
queryClient.setQueryData(query.queryKey, (data) =>
|
||||
removeDownloadTaskFromPages(data as DownloadTaskPages | undefined, taskId),
|
||||
)
|
||||
}
|
||||
queryClient.removeQueries({ queryKey: ['download-task', taskId], exact: true })
|
||||
queryClient.removeQueries({ queryKey: ['download-task-events', taskId], exact: true })
|
||||
}
|
||||
|
||||
export async function syncDownloadTaskChange(queryClient: QueryClient, change: DownloadTaskChange) {
|
||||
if (change.resourceId === '*') {
|
||||
await queryClient.resetQueries({ queryKey: DOWNLOAD_TASKS_QUERY_KEY })
|
||||
return
|
||||
}
|
||||
|
||||
if (change.changeType === 'delete') {
|
||||
removeDownloadTaskCaches(queryClient, change.resourceId)
|
||||
return
|
||||
}
|
||||
|
||||
let item: DownloadTaskListItem
|
||||
try {
|
||||
const task = await queryClient.fetchQuery({
|
||||
queryKey: ['download-task', change.resourceId],
|
||||
queryFn: () => getDownloadTask(change.resourceId),
|
||||
})
|
||||
item = toDownloadTaskListItem(task)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'status' in error && error.status === 404) {
|
||||
removeDownloadTaskCaches(queryClient, change.resourceId)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
for (const query of queryClient.getQueryCache().findAll({ queryKey: DOWNLOAD_TASKS_QUERY_KEY })) {
|
||||
const data = query.state.data as DownloadTaskPages | undefined
|
||||
const containsTask = data?.pages.some((page) => page.items.some((task) => task.id === item.id)) ?? false
|
||||
const matches = matchesDownloadTaskQuery(item, query.queryKey)
|
||||
if (containsTask) {
|
||||
queryClient.setQueryData(query.queryKey, (current) =>
|
||||
updateDownloadTaskPages(current as DownloadTaskPages | undefined, item, query.queryKey),
|
||||
)
|
||||
} else if (
|
||||
matches &&
|
||||
(change.action === 'created' || (change.action === 'status_changed' && Boolean(query.queryKey[1])))
|
||||
) {
|
||||
await queryClient.resetQueries({ queryKey: query.queryKey, exact: true })
|
||||
}
|
||||
}
|
||||
if (change.action !== 'updated') {
|
||||
await queryClient.invalidateQueries({ queryKey: ['download-task-events', change.resourceId], exact: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { toDownloadTaskListItem } from '@shared/download-task'
|
||||
import type { DownloadTask } from '@shared/types'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { Profiler } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDownloadTask, listDownloadTasks } from '@/lib/api'
|
||||
import { DownloadsPage } from './index'
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
class TestIntersectionObserver {
|
||||
readonly root = null
|
||||
readonly rootMargin = ''
|
||||
readonly thresholds = []
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/components/files/hooks/use-files-query', () => ({
|
||||
useFilesQuery: () => ({ data: { items: [] }, isLoading: false }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
createDownloadTask: vi.fn(),
|
||||
getDownloadTask: vi.fn(),
|
||||
listDownloadTaskEvents: vi.fn(),
|
||||
listDownloadTasks: vi.fn(),
|
||||
runDownloadTaskAction: vi.fn(),
|
||||
}))
|
||||
|
||||
function task(id: string, name: string): DownloadTask {
|
||||
return {
|
||||
id,
|
||||
orgId: 'org-1',
|
||||
createdBy: 'user-1',
|
||||
spec: {
|
||||
source: { type: 'http', uri: `https://example.com/${id}.zip` },
|
||||
destination: { folder: '', name },
|
||||
labels: { category: null, tags: [] },
|
||||
},
|
||||
status: {
|
||||
state: 'downloading',
|
||||
attempt: 1,
|
||||
assignment: null,
|
||||
progress: {
|
||||
download: { bytes: 10, totalBytes: 100, bytesPerSecond: 5 },
|
||||
upload: { bytes: 0, totalBytes: 100, bytesPerSecond: 0 },
|
||||
},
|
||||
billing: { state: 'none', authorizedBytes: 0, chargedBytes: 0, chargedCredits: 0 },
|
||||
output: null,
|
||||
runtime: null,
|
||||
error: { message: `${id} detail` },
|
||||
resolveStartedAt: null,
|
||||
resolveCompletedAt: null,
|
||||
downloadCompletedAt: null,
|
||||
ingestStartedAt: null,
|
||||
ingestCompletedAt: null,
|
||||
seedingStartedAt: null,
|
||||
seedingStoppedAt: null,
|
||||
startedAt: '2026-07-27T12:00:00.000Z',
|
||||
finishedAt: null,
|
||||
updatedAt: '2026-07-27T12:00:00.000Z',
|
||||
},
|
||||
createdAt: '2026-07-27T12:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
function renderDownloadsPage(onRender: () => void) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Profiler id="downloads-page" onRender={onRender}>
|
||||
<DownloadsPage />
|
||||
</Profiler>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', TestResizeObserver)
|
||||
vi.stubGlobal('IntersectionObserver', TestIntersectionObserver)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('DownloadsPage task selection', () => {
|
||||
it('loads exactly one detail record for each selected row without a request loop', async () => {
|
||||
const first = task('task-a', 'Task A')
|
||||
const second = task('task-b', 'Task B')
|
||||
vi.mocked(listDownloadTasks).mockResolvedValue({
|
||||
items: [toDownloadTaskListItem(first), toDownloadTaskListItem(second)],
|
||||
nextPageToken: null,
|
||||
})
|
||||
vi.mocked(getDownloadTask).mockImplementation(async (id) => {
|
||||
if (id === first.id) return first
|
||||
if (id === second.id) return second
|
||||
throw new Error(`Unexpected task id: ${id}`)
|
||||
})
|
||||
let renderCount = 0
|
||||
|
||||
renderDownloadsPage(() => {
|
||||
renderCount += 1
|
||||
})
|
||||
|
||||
await waitFor(() => expect(getDownloadTask).toHaveBeenCalledTimes(1))
|
||||
expect(getDownloadTask).toHaveBeenLastCalledWith(first.id)
|
||||
await screen.findByText('task-a detail')
|
||||
|
||||
fireEvent.click(screen.getByText('Task B'))
|
||||
await waitFor(() => expect(getDownloadTask).toHaveBeenCalledTimes(2))
|
||||
expect(getDownloadTask).toHaveBeenLastCalledWith(second.id)
|
||||
await screen.findByText('task-b detail')
|
||||
expect(screen.queryByText('task-a detail')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByText('Task A'))
|
||||
await waitFor(() => expect(getDownloadTask).toHaveBeenCalledTimes(3))
|
||||
expect(getDownloadTask).toHaveBeenLastCalledWith(first.id)
|
||||
await screen.findByText('task-a detail')
|
||||
expect(screen.queryByText('task-b detail')).toBeNull()
|
||||
|
||||
await act(() => new Promise((resolve) => setTimeout(resolve, 50)))
|
||||
expect(getDownloadTask).toHaveBeenCalledTimes(3)
|
||||
expect(renderCount).toBeLessThan(30)
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { DirType } from '@shared/constants'
|
||||
import type {
|
||||
DownloadTask,
|
||||
DownloadTaskAction,
|
||||
DownloadTaskListItem,
|
||||
DownloadTaskStatus,
|
||||
DownloadTaskTimelineItem,
|
||||
StorageObject,
|
||||
@@ -89,15 +90,22 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
|
||||
import { useServerEventSubscription } from '@/hooks/useServerEvents'
|
||||
import { createDownloadTask, listDownloadTaskEvents, listDownloadTasks, runDownloadTaskAction } from '@/lib/api'
|
||||
import {
|
||||
createDownloadTask,
|
||||
getDownloadTask,
|
||||
listDownloadTaskEvents,
|
||||
listDownloadTasks,
|
||||
runDownloadTaskAction,
|
||||
} from '@/lib/api'
|
||||
import { DOWNLOAD_TASKS_QUERY_KEY, type DownloadTaskChange, syncDownloadTaskChange } from '@/lib/download-task-cache'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/downloads/')({
|
||||
component: DownloadsPage,
|
||||
})
|
||||
|
||||
const QUERY_KEY = ['download-tasks']
|
||||
const EMPTY_DOWNLOAD_TASKS: DownloadTask[] = []
|
||||
const QUERY_KEY = DOWNLOAD_TASKS_QUERY_KEY
|
||||
const EMPTY_DOWNLOAD_TASKS: DownloadTaskListItem[] = []
|
||||
const PAUSABLE_STATUSES = new Set<DownloadTaskStatus>(['queued', 'assigned', 'downloading'])
|
||||
const DEFAULT_COLUMN_ORDER = ['select', 'source', 'status', 'progress', 'eta', 'category', 'tags']
|
||||
const STATUS_FILTERS: Array<{ value: DownloadTaskStatus | 'all'; labelKey: string }> = [
|
||||
@@ -116,7 +124,7 @@ type DownloadTaskDisplayStatus = DownloadTaskStatus | 'seeding'
|
||||
type DownloadTaskPhase = NonNullable<NonNullable<DownloadTask['status']['runtime']>['phase']>
|
||||
type DetailTab = 'overview' | 'trackers' | 'peers' | 'files' | 'events'
|
||||
type PanelDragState = { startY: number; startDetailHeight: number; containerHeight: number }
|
||||
type PendingTaskAction = { tasks: DownloadTask[]; action: DownloadTaskAction }
|
||||
type PendingTaskAction = { tasks: DownloadTaskListItem[]; action: DownloadTaskAction }
|
||||
type DetailTableColumn<T> = {
|
||||
id: string
|
||||
label: ReactNode
|
||||
@@ -152,7 +160,7 @@ const DETAIL_TABS: Array<{ id: DetailTab; labelKey: string; icon: ReactNode }> =
|
||||
{ id: 'events', labelKey: 'downloads.detail.tabs.events', icon: <History className="size-4" /> },
|
||||
]
|
||||
|
||||
function DownloadsPage() {
|
||||
export function DownloadsPage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [sourceType, setSourceType] = useState<'http' | 'magnet' | 'torrent_url'>('http')
|
||||
@@ -241,9 +249,10 @@ function DownloadsPage() {
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useServerEventSubscription('download-tasks', ['download_task'], () =>
|
||||
queryClient.invalidateQueries({ queryKey: QUERY_KEY }),
|
||||
)
|
||||
useServerEventSubscription('download-tasks', ['download_task'], (data) => {
|
||||
const change = data as DownloadTaskChange
|
||||
void syncDownloadTaskChange(queryClient, change)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!panelDrag) return
|
||||
@@ -289,7 +298,7 @@ function DownloadsPage() {
|
||||
})
|
||||
|
||||
const actionMutation = useMutation({
|
||||
mutationFn: async ({ tasks, action }: { tasks: DownloadTask[]; action: DownloadTaskAction }) => {
|
||||
mutationFn: async ({ tasks, action }: { tasks: DownloadTaskListItem[]; action: DownloadTaskAction }) => {
|
||||
const actionable = tasks.filter((task) => taskActions(task).includes(action))
|
||||
await Promise.all(actionable.map((task) => runDownloadTaskAction(task.id, action)))
|
||||
return { action, count: actionable.length }
|
||||
@@ -313,7 +322,7 @@ function DownloadsPage() {
|
||||
})
|
||||
}
|
||||
|
||||
function handleTaskAction(task: DownloadTask, action: DownloadTaskAction) {
|
||||
function handleTaskAction(task: DownloadTaskListItem, action: DownloadTaskAction) {
|
||||
requestTaskAction({ tasks: [task], action })
|
||||
}
|
||||
|
||||
@@ -321,7 +330,7 @@ function DownloadsPage() {
|
||||
requestTaskAction({ tasks: selectedTasks, action })
|
||||
}
|
||||
|
||||
function handlePrimaryTaskAction(task: DownloadTask) {
|
||||
function handlePrimaryTaskAction(task: DownloadTaskListItem) {
|
||||
const action = primaryTaskAction(task)
|
||||
if (action) handleTaskAction(task, action)
|
||||
}
|
||||
@@ -353,7 +362,10 @@ function DownloadsPage() {
|
||||
setPendingTaskAction(null)
|
||||
}
|
||||
|
||||
const tasks = tasksQuery.data?.pages.flatMap((page) => page.items) ?? EMPTY_DOWNLOAD_TASKS
|
||||
const tasks = useMemo(
|
||||
() => tasksQuery.data?.pages.flatMap((page) => page.items) ?? EMPTY_DOWNLOAD_TASKS,
|
||||
[tasksQuery.data],
|
||||
)
|
||||
const nonSourceTableWidth =
|
||||
DOWNLOAD_SELECT_COLUMN_WIDTH +
|
||||
downloadColumnWidth(columnSizing, 'status') +
|
||||
@@ -378,8 +390,13 @@ function DownloadsPage() {
|
||||
enableRowSelection: true,
|
||||
columnResizeMode: 'onChange',
|
||||
})
|
||||
const selectedTask = tasks.find((task) => task.id === selectedTaskId) ?? tasks[0] ?? null
|
||||
const activeSelectedTaskId = selectedTask?.id ?? null
|
||||
const selectedListItem = tasks.find((task) => task.id === selectedTaskId) ?? tasks[0] ?? null
|
||||
const activeSelectedTaskId = selectedListItem?.id ?? null
|
||||
const selectedTaskQuery = useQuery({
|
||||
queryKey: ['download-task', activeSelectedTaskId],
|
||||
queryFn: () => getDownloadTask(activeSelectedTaskId as string),
|
||||
enabled: activeSelectedTaskId !== null,
|
||||
})
|
||||
const selectedTasks = table.getSelectedRowModel().rows.map((row) => row.original)
|
||||
|
||||
function handlePanelResizeStart(event: ReactPointerEvent<HTMLButtonElement>) {
|
||||
@@ -633,7 +650,12 @@ function DownloadsPage() {
|
||||
</button>
|
||||
|
||||
<section className="min-h-0 overflow-hidden rounded-md border bg-background">
|
||||
<DownloadInspector task={selectedTask} tab={detailTab} onTabChange={setDetailTab} />
|
||||
<DownloadInspector
|
||||
task={selectedTaskQuery.data ?? null}
|
||||
loading={selectedTaskQuery.isLoading}
|
||||
tab={detailTab}
|
||||
onTabChange={setDetailTab}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -734,7 +756,7 @@ function DownloadTableHead({
|
||||
onDragStart,
|
||||
onDrop,
|
||||
}: {
|
||||
header: Header<DownloadTask, unknown>
|
||||
header: Header<DownloadTaskListItem, unknown>
|
||||
draggedColumnId: string | null
|
||||
onDragStart: (columnId: string | null) => void
|
||||
onDrop: (columnId: string) => void
|
||||
@@ -799,7 +821,7 @@ function DownloadTableHead({
|
||||
function getDownloadColumns(
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
sourceColumnWidth: number,
|
||||
): ColumnDef<DownloadTask>[] {
|
||||
): ColumnDef<DownloadTaskListItem>[] {
|
||||
return [
|
||||
{
|
||||
id: 'select',
|
||||
@@ -901,7 +923,7 @@ function TagsCell({ tags }: { tags: string[] }) {
|
||||
return <span className="block min-w-0 truncate text-xs text-muted-foreground">{tags.join(' / ')}</span>
|
||||
}
|
||||
|
||||
function ProgressCell({ task }: { task: DownloadTask }) {
|
||||
function ProgressCell({ task }: { task: DownloadTaskListItem }) {
|
||||
const { t } = useTranslation()
|
||||
const progress = transferProgress(task)
|
||||
const activeTransfer = currentTransferProgress(task)
|
||||
@@ -932,7 +954,7 @@ function BulkTaskActions({
|
||||
onAction,
|
||||
onClear,
|
||||
}: {
|
||||
tasks: DownloadTask[]
|
||||
tasks: DownloadTaskListItem[]
|
||||
pending: boolean
|
||||
onAction: (action: DownloadTaskAction) => void
|
||||
onClear: () => void
|
||||
@@ -1162,7 +1184,7 @@ function TaskRow({
|
||||
onPrimaryAction,
|
||||
onAction,
|
||||
}: {
|
||||
row: Row<DownloadTask>
|
||||
row: Row<DownloadTaskListItem>
|
||||
selected: boolean
|
||||
actionPending: boolean
|
||||
onSelect: () => void
|
||||
@@ -1202,7 +1224,7 @@ function TaskContextMenu({
|
||||
pending,
|
||||
onAction,
|
||||
}: {
|
||||
task: DownloadTask
|
||||
task: DownloadTaskListItem
|
||||
pending: boolean
|
||||
onAction: (action: DownloadTaskAction) => void
|
||||
}) {
|
||||
@@ -1261,7 +1283,7 @@ function TaskMenuItem({
|
||||
)
|
||||
}
|
||||
|
||||
function taskActions(task: DownloadTask): DownloadTaskAction[] {
|
||||
function taskActions(task: DownloadTaskListItem): DownloadTaskAction[] {
|
||||
if (PAUSABLE_STATUSES.has(task.status.state)) return ['pause', 'cancel']
|
||||
if (task.status.state === 'paused') return ['resume', 'restart', 'cancel']
|
||||
if (task.status.state === 'suspended') return ['resume', 'restart', 'cancel']
|
||||
@@ -1273,12 +1295,12 @@ function taskActions(task: DownloadTask): DownloadTaskAction[] {
|
||||
return []
|
||||
}
|
||||
|
||||
function availableBulkActions(tasks: DownloadTask[]): DownloadTaskAction[] {
|
||||
function availableBulkActions(tasks: DownloadTaskListItem[]): DownloadTaskAction[] {
|
||||
const orderedActions: DownloadTaskAction[] = ['pause', 'resume', 'cancel', 'retry', 'restart', 'delete']
|
||||
return orderedActions.filter((action) => tasks.some((task) => taskActions(task).includes(action)))
|
||||
}
|
||||
|
||||
function primaryTaskAction(task: DownloadTask): DownloadTaskAction | null {
|
||||
function primaryTaskAction(task: DownloadTaskListItem): DownloadTaskAction | null {
|
||||
if (PAUSABLE_STATUSES.has(task.status.state)) return 'pause'
|
||||
if (task.status.state === 'paused' || task.status.state === 'suspended') return 'resume'
|
||||
if (task.status.state === 'interrupted') return 'restart'
|
||||
@@ -1295,7 +1317,7 @@ function TaskActionIcon({ action }: { action: DownloadTaskAction }) {
|
||||
return <XCircle />
|
||||
}
|
||||
|
||||
function TransferProgress({ task, className }: { task: DownloadTask; className?: string }) {
|
||||
function TransferProgress({ task, className }: { task: DownloadTaskListItem; className?: string }) {
|
||||
const progress = transferProgress(task)
|
||||
return (
|
||||
<div
|
||||
@@ -1316,17 +1338,17 @@ function TransferProgress({ task, className }: { task: DownloadTask; className?:
|
||||
)
|
||||
}
|
||||
|
||||
function transferProgress(task: DownloadTask) {
|
||||
function transferProgress(task: DownloadTaskListItem) {
|
||||
const download = transferPercent(task.status.progress.download, task.status.state === 'completed')
|
||||
const upload = transferPercent(task.status.progress.upload, task.status.state === 'completed')
|
||||
return { download, upload, overall: task.status.state === 'uploading' || upload > 0 ? upload : download }
|
||||
}
|
||||
|
||||
function currentTransferProgress(task: DownloadTask) {
|
||||
function currentTransferProgress(task: DownloadTaskListItem) {
|
||||
return task.status.state === 'uploading' ? task.status.progress.upload : task.status.progress.download
|
||||
}
|
||||
|
||||
function transferPercent(progress: DownloadTask['status']['progress']['download'], complete: boolean) {
|
||||
function transferPercent(progress: DownloadTaskListItem['status']['progress']['download'], complete: boolean) {
|
||||
if (complete) return 100
|
||||
if (!progress.totalBytes || progress.totalBytes <= 0) return 0
|
||||
return Math.min(100, Math.round((progress.bytes / progress.totalBytes) * 100))
|
||||
@@ -1334,10 +1356,12 @@ function transferPercent(progress: DownloadTask['status']['progress']['download'
|
||||
|
||||
function DownloadInspector({
|
||||
task,
|
||||
loading,
|
||||
tab,
|
||||
onTabChange,
|
||||
}: {
|
||||
task: DownloadTask | null
|
||||
loading: boolean
|
||||
tab: DetailTab
|
||||
onTabChange: (tab: DetailTab) => void
|
||||
}) {
|
||||
@@ -1346,7 +1370,7 @@ function DownloadInspector({
|
||||
if (!task) {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 items-center justify-center text-sm text-muted-foreground">
|
||||
{t('downloads.detail.noSelection')}
|
||||
{loading ? t('common.loading') : t('downloads.detail.noSelection')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1864,17 +1888,17 @@ function timelineDetail(event: DownloadTaskTimelineItem, t: ReturnType<typeof us
|
||||
return event.detail
|
||||
}
|
||||
|
||||
function SourceIcon({ type }: { type: DownloadTask['spec']['source']['type'] }) {
|
||||
function SourceIcon({ type }: { type: DownloadTaskListItem['spec']['source']['type'] }) {
|
||||
if (type === 'magnet') return <Magnet className="size-4 shrink-0 text-amber-500" />
|
||||
if (type === 'torrent_url') return <FileDown className="size-4 shrink-0 text-violet-500" />
|
||||
return <LinkIcon className="size-4 shrink-0 text-blue-500" />
|
||||
}
|
||||
|
||||
function sourceType(task: DownloadTask): DownloadTask['spec']['source']['type'] {
|
||||
function sourceType(task: DownloadTaskListItem): DownloadTaskListItem['spec']['source']['type'] {
|
||||
return task.spec.source.type
|
||||
}
|
||||
|
||||
function sourceUri(task: DownloadTask): string {
|
||||
function sourceUri(task: DownloadTaskListItem): string {
|
||||
return task.spec.source.uri
|
||||
}
|
||||
|
||||
@@ -1907,7 +1931,7 @@ function EmptyPanel({ text }: { text: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function getTaskTitle(task: DownloadTask) {
|
||||
function getTaskTitle(task: DownloadTaskListItem) {
|
||||
return (
|
||||
task.status.runtime?.torrent?.name ||
|
||||
task.spec.destination.name ||
|
||||
@@ -1930,7 +1954,7 @@ function filenameFromUri(uri: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function sourceTypeKey(task: DownloadTask) {
|
||||
function sourceTypeKey(task: DownloadTaskListItem) {
|
||||
if (sourceType(task) === 'torrent_url') return 'torrentUrl'
|
||||
return sourceType(task)
|
||||
}
|
||||
@@ -1953,7 +1977,7 @@ function formatDate(value: string | null | undefined) {
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function displayStatus(task: DownloadTask): DownloadTaskDisplayStatus {
|
||||
function displayStatus(task: DownloadTaskListItem): DownloadTaskDisplayStatus {
|
||||
if (task.status.state === 'completed' && task.status.runtime?.phase === 'seeding') return 'seeding'
|
||||
return task.status.state
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user