Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions _docs/task-batch-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Batch task creation

`POST /api/v1/tasks/batch` accepts ordinary and grouped batches:

```json
{
"group": true,
"groupName": "album",
"opts": {"path": "/downloads"},
"reqs": [
{"rid": "resolved-resource-id", "opts": {"selectFiles": [1]}},
{"req": {"url": "https://example.com/cover.jpg"}}
]
}
```

Each member accepts `rid` or `req`. A nonempty `rid` takes priority and creates a
task from the cached resolve result. A member's `opts`, when present, replaces
batch `opts` as a whole; omitted fields do not inherit individual batch values.
Members without `opts` use batch `opts`. BT file selections use the indexes from
the resolve result. Requests without `rid` continue to work.

For groups, batch `opts.path` selects the parent directory. Members inheriting
batch options, or with an empty path, use the reserved group directory. Explicit
relative member paths are resolved under that directory; do not prefix them with
the batch parent path. For example, parent `downloads` and member path `nested`
place the member in `downloads/<group name>/nested`. Absolute paths under the
parent directory preserve their relative structure within the group. In ordinary
batches, an empty member path uses the configured default download directory.

All members are attempted. If at least one task is persisted, the response is
successful and contains the created task IDs. A task that fails to start after
being persisted is included; use the existing task controls to retry it.
Successful tasks are kept when other members fail. If every member fails, the
API returns an error and removes any newly reserved empty group and directory.

Both ordinary and grouped batches now return an object in `data`:

```json
{"code": 0, "msg": "", "data": {"taskIds": ["task-1", "task-2"], "parentId": "group-id"}}
```

Ordinary batches omit `parentId`. **This replaces the former array response for
ordinary batches.** The outer `code`/`msg`/`data` envelope is unchanged. Clients
must read `data.taskIds`; there are no per-member results. A successful response
completes the batch, including partial success, so clients must not automatically
resubmit missing members.

With `group: true`, the server generates the group ID and reserves its directory.
Creation does not accept a client group ID or append to an existing group. Group
metadata remains available through the existing task list and detail responses;
`parentId` identifies membership for group operations.
4 changes: 2 additions & 2 deletions pkg/api/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,11 @@ func (s *Service) createTaskBatch(ctx *Context) *Response {
if len(request.Reqs) == 0 {
return newErrorResponse("param invalid: reqs", restmodel.CodeInvalidParam)
}
taskIDs, err := s.Downloader.CreateDirectBatch(&request)
result, err := s.Downloader.CreateDirectBatchWithResult(&request)
if err != nil {
return newErrorResponse(err.Error())
}
return newOKResponse(taskIDs)
return newOKResponse(result)
}

func (s *Service) patchTask(ctx *Context) *Response {
Expand Down
62 changes: 62 additions & 0 deletions pkg/api/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,65 @@ func TestFullConfigAndJobTriggerShareProtectedProtocolState(t *testing.T) {
t.Fatalf("job state overwritten: %s", raw)
}
}

func TestBatchResolvedAndDirectCreation(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "4")
_, _ = w.Write([]byte("test"))
}))
t.Cleanup(upstream.Close)
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&httpProtocol.FetcherManager{}}})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
cfg, err := d.GetConfig()
if err != nil {
t.Fatal(err)
}
cfg.MaxRunning = 0
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
service, err := NewService(d)
if err != nil {
t.Fatal(err)
}
for _, grouped := range []bool{false, true} {
resolved := serviceTestRequest[*download.ResolveResult](t, service, http.MethodPost, "/api/v1/resolve", &restmodel.ResolveTask{Req: &base.Request{URL: upstream.URL + "/resolved"}})
result := serviceTestRequest[map[string]json.RawMessage](t, service, http.MethodPost, "/api/v1/tasks/batch", &base.CreateTaskBatch{
Group: grouped, GroupName: "folder", Opts: &base.Options{Path: t.TempDir()},
Reqs: []*base.CreateTaskBatchItem{
{Rid: "invalid"},
{Rid: resolved.ID, Req: &base.Request{URL: "unsupported://ignored"}, Opts: &base.Options{SelectFiles: []int{0}}},
{Req: &base.Request{URL: upstream.URL + "/direct"}},
},
})
var ids []string
if err := json.Unmarshal(result["taskIds"], &ids); err != nil || len(ids) != 2 {
t.Fatalf("unexpected task IDs: %+v, %v", result, err)
}
wantFields := 1
if grouped {
wantFields = 2
}
if len(result) != wantFields {
t.Fatalf("unexpected response fields: %+v", result)
}
var parentID string
if grouped {
if err := json.Unmarshal(result["parentId"], &parentID); err != nil || parentID == "" {
t.Fatalf("missing parent ID: %+v", result)
}
}
task := serviceTestRequest[*download.Task](t, service, http.MethodGet, "/api/v1/tasks/"+ids[0], nil)
if task.ParentID != parentID || !reflect.DeepEqual(task.Meta.Opts.SelectFiles, []int{0}) {
t.Fatalf("resolved task lost membership or selection: %+v", task)
}
status, response := service.JSON(http.MethodPost, "/api/v1/tasks/batch", nil, []byte(`{"reqs":[{"rid":"missing"},{"req":{"url":"unsupported://bad"}}]}`), nil)
var failed restmodel.Result[any]
if err := json.Unmarshal(response, &failed); err != nil || status != http.StatusOK || failed.Code == restmodel.CodeOk {
t.Fatalf("all-failed batch succeeded: %d %s", status, response)
}
}
}
7 changes: 5 additions & 2 deletions pkg/base/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,11 +200,14 @@ func ParseOptExtra[E any](opts *Options) error {
}

type CreateTaskBatch struct {
Reqs []*CreateTaskBatchItem `json:"reqs"`
Opts *Options `json:"opts"`
Reqs []*CreateTaskBatchItem `json:"reqs"`
Opts *Options `json:"opts"`
Group bool `json:"group"`
GroupName string `json:"groupName"`
}

type CreateTaskBatchItem struct {
Rid string `json:"rid,omitempty"`
Req *Request `json:"req"`
Opts *Options `json:"opts"`
}
Expand Down
7 changes: 7 additions & 0 deletions pkg/download/category.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ func categoryForResource(cfg *base.DownloaderStoreConfig, res *base.Resource) *b
// runs on the result, so a category directory the white list rejects falls
// back to the base directory of this task instead of failing it.
func (d *Downloader) decideDownloadDir(opts *base.Options, res *base.Resource) (string, error) {
return d.decideDownloadDirForTask(opts, res, nil)
}

func (d *Downloader) decideDownloadDirForTask(opts *base.Options, res *base.Resource, group *TaskGroup) (string, error) {
if opts == nil {
opts = &base.Options{}
}
Expand All @@ -48,6 +52,9 @@ func (d *Downloader) decideDownloadDir(opts *base.Options, res *base.Resource) (
if base == "" {
return "", errors.New("download directory is empty")
}
if group != nil && opts.SkipAutoCategorize {
return d.initDownloadPathForTask(base, group)
}
target := base
if !opts.SkipAutoCategorize {
if category := categoryForResource(cfg, res); category != nil {
Expand Down
32 changes: 32 additions & 0 deletions pkg/download/category_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,38 @@ func TestDecideDownloadDirWhiteList(t *testing.T) {
})
}

func TestDecideGroupDownloadDirWhiteList(t *testing.T) {
parent := t.TempDir()
root := filepath.Join(parent, "folder")
nested := filepath.Join(root, "nested")
for _, test := range []struct {
name string
path string
group *TaskGroup
allowed string
wantErr bool
}{
{name: "group root", path: root, group: &TaskGroup{Path: root}, allowed: filepath.Join(parent, "*")},
{name: "nested member", path: nested, group: &TaskGroup{Path: root}, allowed: filepath.Join(parent, "*")},
{name: "ordinary nested task", path: nested, allowed: filepath.Join(parent, "*"), wantErr: true},
{name: "member outside group", path: filepath.Join(parent, "other"), group: &TaskGroup{Path: root}, allowed: filepath.Join(parent, "*"), wantErr: true},
{name: "group permission revoked", path: nested, group: &TaskGroup{Path: root}, allowed: nested, wantErr: true},
{name: "literal group name", path: filepath.Join(parent, "%year%", "nested"), group: &TaskGroup{Path: filepath.Join(parent, "%year%")}, allowed: filepath.Join(parent, "*")},
} {
t.Run(test.name, func(t *testing.T) {
d := newUnitCategoryDownloader(newTestCategoryConfig())
d.cfg.WhiteDownloadDirs = []string{test.allowed}
got, err := d.decideDownloadDirForTask(&base.Options{Path: test.path, SkipAutoCategorize: true}, singleFileResource("file.txt"), test.group)
if (err != nil) != test.wantErr {
t.Fatalf("decideDownloadDirForTask() = %q, %v; want error = %v", got, err, test.wantErr)
}
if err == nil && got != test.path {
t.Fatalf("path = %q, want %q", got, test.path)
}
})
}
}

func TestSuggestedDownloadDir(t *testing.T) {
programDir := filepath.Join("./downloads", "Program")

Expand Down
Loading
Loading