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:
Jasper Van
2026-07-27 10:22:47 -04:00
committed by GitHub
parent ad0f21bb39
commit 5473db9be1
23 changed files with 1276 additions and 192 deletions
+7 -9
View File
@@ -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
+4 -4
View File
@@ -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)
}
}
+379 -40
View File
@@ -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
}
+16 -4
View File
@@ -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
+42 -20
View File
@@ -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
View File
@@ -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)
+76 -43
View File
@@ -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
+11
View File
@@ -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)
}
+5 -1
View File
@@ -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. */
+43
View File
@@ -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()
})
})
+21
View File
@@ -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,
}
}
+28 -1
View File
@@ -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),
+4
View File
@@ -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,
+1
View File
@@ -273,6 +273,7 @@ export type DownloadTaskBillingState = 'none' | 'ok' | 'insufficient_credits'
export type {
DownloadTask,
DownloadTaskEvent,
DownloadTaskListItem,
DownloadTaskTimeline,
DownloadTaskTimelineItem,
} from '../schemas/downloads'
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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() {
+231
View File
@@ -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 })
})
})
+112
View File
@@ -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)
})
})
+59 -35
View File
@@ -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
}