@pippit-dev/cli 0.0.21 → 0.0.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/checksums.txt +6 -6
- package/cmd/short_drama/short_drama.go +6 -1
- package/cmd/short_drama_test.go +158 -10
- package/internal/common/access_key.go +1 -1
- package/internal/common/download_results.go +35 -16
- package/internal/common/list_thread_file.go +3 -0
- package/package.json +1 -1
- package/skills/short-drama/SKILL.md +21 -10
package/README.md
CHANGED
|
@@ -198,13 +198,13 @@ pippit-tool-cli --version
|
|
|
198
198
|
pippit-tool-cli short-drama +submit-run --message "写一个赛博朋克短剧开头"
|
|
199
199
|
pippit-tool-cli short-drama +upload-file --path ./reference.doc
|
|
200
200
|
pippit-tool-cli short-drama +get-thread --thread-id thread_123 --run-id run_456
|
|
201
|
-
pippit-tool-cli short-drama +download-result --output-path ./thread_123/results/result.mp4 --url URL
|
|
201
|
+
pippit-tool-cli short-drama +download-result --output-path ./thread_123/results/result.mp4 --url URL --updated-at 1779716734
|
|
202
202
|
```
|
|
203
203
|
|
|
204
204
|
`+submit-run`: 输出 `thread_id`、`run_id` 和 `web_thread_link`;其中 `--message` 为必填参数。
|
|
205
205
|
`+get-thread`: 请求中带 `version=v2`,并输出 `readable_text`。
|
|
206
206
|
`+upload-file`: 输出返回的 `asset_id`。 当前仅支持 `.doc`、`.docx` 和 `.txt` 文件。
|
|
207
|
-
`+download-result`: 会把结果 URL 下载到 `--output-path`
|
|
207
|
+
`+download-result`: 会把结果 URL 下载到 `--output-path` 指定的文件路径;传入 `--updated-at` 后,如果本地文件早于该时间戳会覆盖更新,否则跳过。
|
|
208
208
|
|
|
209
209
|
短剧命令的错误日志会追加写入本地每日日志文件:`~/.pippit_tool_cli/logs/yyyy-mm-dd.log`。日志路径会基于当前用户主目录和系统路径分隔符生成,因此可在 macOS、Linux 和 Windows 上使用。
|
|
210
210
|
|
package/checksums.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
6622f32c85491b9287ac690b269ad42a67c652793261c1068a1a39958505db31 pippit-tool-cli-0.0.23-darwin-amd64.tar.gz
|
|
2
|
+
fe18af3140b0def41eb954e671d81a9e4fd6e9973c87a942c6b7cd59363731e3 pippit-tool-cli-0.0.23-darwin-arm64.tar.gz
|
|
3
|
+
9a5b56a42a0611a540d30f04ba50ce430040188c67446bcd42bd0297c6bbee60 pippit-tool-cli-0.0.23-linux-amd64.tar.gz
|
|
4
|
+
bad0a2c0d311bda3fce24652a4116b1d620fc12008a1f5b2ea77017f677dd9c8 pippit-tool-cli-0.0.23-linux-arm64.tar.gz
|
|
5
|
+
cb44ff96743bd5a47eec7d05b5a228b491c36907ba2f013af4c7b2259b18781f pippit-tool-cli-0.0.23-windows-amd64.zip
|
|
6
|
+
70ea30297e07c800a14c4aa52eecc6d9ea7f78a259892b5f9efc05d9e7385d22 pippit-tool-cli-0.0.23-windows-arm64.zip
|
|
@@ -104,11 +104,15 @@ func newShortDramaDownloadResultCommand(stdout, stderr io.Writer, runner *common
|
|
|
104
104
|
Short: "Download a generated result URL",
|
|
105
105
|
Args: cobra.NoArgs,
|
|
106
106
|
RunE: withErrorLog("short-drama +download-result", func() map[string]string {
|
|
107
|
-
|
|
107
|
+
fields := map[string]string{
|
|
108
108
|
"output_path": opts.OutputPath,
|
|
109
109
|
"has_url": strconv.FormatBool(strings.TrimSpace(opts.URL) != ""),
|
|
110
110
|
"workers": strconv.Itoa(opts.Workers),
|
|
111
111
|
}
|
|
112
|
+
if opts.UpdatedAt > 0 {
|
|
113
|
+
fields["updated_at"] = strconv.FormatInt(opts.UpdatedAt, 10)
|
|
114
|
+
}
|
|
115
|
+
return fields
|
|
112
116
|
}, func(cmd *cobra.Command, _ []string) error {
|
|
113
117
|
opts.OutputPath = strings.TrimSpace(opts.OutputPath)
|
|
114
118
|
if opts.OutputPath == "" {
|
|
@@ -133,6 +137,7 @@ func newShortDramaDownloadResultCommand(stdout, stderr io.Writer, runner *common
|
|
|
133
137
|
cmd.SetErr(stderr)
|
|
134
138
|
cmd.Flags().StringVar(&opts.URL, "url", "", "URL to download")
|
|
135
139
|
cmd.Flags().StringVar(&opts.OutputPath, "output-path", "", "local output file path")
|
|
140
|
+
cmd.Flags().Int64Var(&opts.UpdatedAt, "updated-at", 0, "remote file update time as a Unix timestamp")
|
|
136
141
|
cmd.Flags().IntVar(&opts.Workers, "workers", 5, "parallel download workers")
|
|
137
142
|
return cmd
|
|
138
143
|
}
|
package/cmd/short_drama_test.go
CHANGED
|
@@ -184,9 +184,7 @@ func TestShortDramaSubmitRunRequiresAccessKey(t *testing.T) {
|
|
|
184
184
|
if err == nil {
|
|
185
185
|
t.Fatal("Execute() error = nil, want access key error")
|
|
186
186
|
}
|
|
187
|
-
|
|
188
|
-
t.Fatalf("error = %q, want access key guidance", err)
|
|
189
|
-
}
|
|
187
|
+
assertAccessKeyGuidance(t, err)
|
|
190
188
|
}
|
|
191
189
|
|
|
192
190
|
func TestShortDramaUploadFile(t *testing.T) {
|
|
@@ -286,9 +284,7 @@ func TestShortDramaUploadFileRequiresAccessKey(t *testing.T) {
|
|
|
286
284
|
if err == nil {
|
|
287
285
|
t.Fatal("Execute() error = nil, want access key error")
|
|
288
286
|
}
|
|
289
|
-
|
|
290
|
-
t.Fatalf("error = %q, want access key guidance", err)
|
|
291
|
-
}
|
|
287
|
+
assertAccessKeyGuidance(t, err)
|
|
292
288
|
}
|
|
293
289
|
|
|
294
290
|
func TestShortDramaUploadFileRejectsUnsupportedFileType(t *testing.T) {
|
|
@@ -408,6 +404,111 @@ func TestShortDramaDownloadResultSkipsExistingFile(t *testing.T) {
|
|
|
408
404
|
assertFileContent(t, outputPath, "existing-data")
|
|
409
405
|
}
|
|
410
406
|
|
|
407
|
+
func TestShortDramaDownloadResultSkipsExistingFileWhenLocalIsFresh(t *testing.T) {
|
|
408
|
+
serverCalled := false
|
|
409
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
410
|
+
serverCalled = true
|
|
411
|
+
t.Fatal("server should not receive request when target file is fresh")
|
|
412
|
+
}))
|
|
413
|
+
defer server.Close()
|
|
414
|
+
|
|
415
|
+
chdirTemp(t)
|
|
416
|
+
outputPath := filepath.Join("results", "cover.jpeg")
|
|
417
|
+
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
|
418
|
+
t.Fatalf("MkdirAll(): %v", err)
|
|
419
|
+
}
|
|
420
|
+
if err := os.WriteFile(outputPath, []byte("existing-data"), 0o644); err != nil {
|
|
421
|
+
t.Fatalf("WriteFile(): %v", err)
|
|
422
|
+
}
|
|
423
|
+
remoteUpdatedAt := int64(1779716734)
|
|
424
|
+
localUpdatedAt := time.Unix(remoteUpdatedAt+10, 0)
|
|
425
|
+
if err := os.Chtimes(outputPath, localUpdatedAt, localUpdatedAt); err != nil {
|
|
426
|
+
t.Fatalf("Chtimes(): %v", err)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
var stdout, stderr bytes.Buffer
|
|
430
|
+
root := NewRootCommand(&stdout, &stderr)
|
|
431
|
+
root.SetArgs([]string{
|
|
432
|
+
"short-drama", "+download-result",
|
|
433
|
+
"--output-path", outputPath,
|
|
434
|
+
"--updated-at", "1779716734",
|
|
435
|
+
"--url", server.URL + "/image",
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
if err := root.Execute(); err != nil {
|
|
439
|
+
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
440
|
+
}
|
|
441
|
+
if serverCalled {
|
|
442
|
+
t.Fatal("server was called, want fresh existing file to skip download")
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
got := decodeJSON(t, stdout.Bytes())
|
|
446
|
+
alreadyExist, ok := got["already_exist"].([]any)
|
|
447
|
+
if !ok || len(alreadyExist) != 1 || alreadyExist[0] != outputPath {
|
|
448
|
+
t.Fatalf("already_exist = %#v, want existing output path", got["already_exist"])
|
|
449
|
+
}
|
|
450
|
+
assertFileContent(t, outputPath, "existing-data")
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
func TestShortDramaDownloadResultOverwritesStaleExistingFile(t *testing.T) {
|
|
454
|
+
serverCalled := false
|
|
455
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
456
|
+
serverCalled = true
|
|
457
|
+
if r.URL.Path != "/image" {
|
|
458
|
+
t.Fatalf("path = %s, want /image", r.URL.Path)
|
|
459
|
+
}
|
|
460
|
+
_, _ = w.Write([]byte("new-data"))
|
|
461
|
+
}))
|
|
462
|
+
defer server.Close()
|
|
463
|
+
|
|
464
|
+
chdirTemp(t)
|
|
465
|
+
outputPath := filepath.Join("results", "cover.jpeg")
|
|
466
|
+
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
|
467
|
+
t.Fatalf("MkdirAll(): %v", err)
|
|
468
|
+
}
|
|
469
|
+
if err := os.WriteFile(outputPath, []byte("existing-data"), 0o644); err != nil {
|
|
470
|
+
t.Fatalf("WriteFile(): %v", err)
|
|
471
|
+
}
|
|
472
|
+
remoteUpdatedAt := int64(1779716734)
|
|
473
|
+
staleUpdatedAt := time.Unix(remoteUpdatedAt-10, 0)
|
|
474
|
+
if err := os.Chtimes(outputPath, staleUpdatedAt, staleUpdatedAt); err != nil {
|
|
475
|
+
t.Fatalf("Chtimes(): %v", err)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
var stdout, stderr bytes.Buffer
|
|
479
|
+
root := NewRootCommand(&stdout, &stderr)
|
|
480
|
+
root.SetArgs([]string{
|
|
481
|
+
"short-drama", "+download-result",
|
|
482
|
+
"--output-path", outputPath,
|
|
483
|
+
"--updated-at", "1779716734",
|
|
484
|
+
"--url", server.URL + "/image",
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
if err := root.Execute(); err != nil {
|
|
488
|
+
t.Fatalf("Execute() error = %v, stderr = %s", err, stderr.String())
|
|
489
|
+
}
|
|
490
|
+
if !serverCalled {
|
|
491
|
+
t.Fatal("server was not called, want stale existing file to be downloaded")
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
got := decodeJSON(t, stdout.Bytes())
|
|
495
|
+
downloaded, ok := got["downloaded"].([]any)
|
|
496
|
+
if !ok || len(downloaded) != 1 || downloaded[0] != outputPath {
|
|
497
|
+
t.Fatalf("downloaded = %#v, want output path", got["downloaded"])
|
|
498
|
+
}
|
|
499
|
+
if _, ok := got["overwritten"]; ok {
|
|
500
|
+
t.Fatalf("overwritten should not be returned: %#v", got)
|
|
501
|
+
}
|
|
502
|
+
assertFileContent(t, outputPath, "new-data")
|
|
503
|
+
info, err := os.Stat(outputPath)
|
|
504
|
+
if err != nil {
|
|
505
|
+
t.Fatalf("Stat(%s): %v", outputPath, err)
|
|
506
|
+
}
|
|
507
|
+
if info.ModTime().Unix() != remoteUpdatedAt {
|
|
508
|
+
t.Fatalf("mtime = %d, want %d", info.ModTime().Unix(), remoteUpdatedAt)
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
411
512
|
func TestShortDramaDownloadResultDownloadsMetaJSON(t *testing.T) {
|
|
412
513
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
413
514
|
if r.URL.Path != "/meta.json" {
|
|
@@ -513,6 +614,7 @@ func TestShortDramaDownloadResultAllFailed(t *testing.T) {
|
|
|
513
614
|
defer server.Close()
|
|
514
615
|
|
|
515
616
|
chdirTemp(t)
|
|
617
|
+
clearDailyErrorLog(t)
|
|
516
618
|
var stdout, stderr bytes.Buffer
|
|
517
619
|
root := NewRootCommand(&stdout, &stderr)
|
|
518
620
|
root.SetArgs([]string{
|
|
@@ -528,6 +630,17 @@ func TestShortDramaDownloadResultAllFailed(t *testing.T) {
|
|
|
528
630
|
if !strings.Contains(err.Error(), "all 1 download(s) failed") {
|
|
529
631
|
t.Fatalf("error = %q, want all-failed message", err)
|
|
530
632
|
}
|
|
633
|
+
logs := readDailyErrorLog(t)
|
|
634
|
+
if len(logs) != 1 {
|
|
635
|
+
t.Fatalf("logs = %#v, want one error log", logs)
|
|
636
|
+
}
|
|
637
|
+
fields, ok := logs[0]["fields"].(map[string]any)
|
|
638
|
+
if !ok {
|
|
639
|
+
t.Fatalf("fields = %#v, want object", logs[0]["fields"])
|
|
640
|
+
}
|
|
641
|
+
if _, ok := fields["updated_at"]; ok {
|
|
642
|
+
t.Fatalf("updated_at should be omitted when --updated-at is not provided: %#v", fields)
|
|
643
|
+
}
|
|
531
644
|
}
|
|
532
645
|
|
|
533
646
|
func TestShortDramaDownloadResultOutputPath(t *testing.T) {
|
|
@@ -651,9 +764,7 @@ func TestShortDramaGetThreadRequiresAccessKey(t *testing.T) {
|
|
|
651
764
|
if err == nil {
|
|
652
765
|
t.Fatal("Execute() error = nil, want access key error")
|
|
653
766
|
}
|
|
654
|
-
|
|
655
|
-
t.Fatalf("error = %q, want access key guidance", err)
|
|
656
|
-
}
|
|
767
|
+
assertAccessKeyGuidance(t, err)
|
|
657
768
|
}
|
|
658
769
|
|
|
659
770
|
func TestShortDramaListThreadFile(t *testing.T) {
|
|
@@ -684,7 +795,7 @@ func TestShortDramaListThreadFile(t *testing.T) {
|
|
|
684
795
|
if body["page_size"] != float64(10) {
|
|
685
796
|
t.Fatalf("page_size = %v, want 10", body["page_size"])
|
|
686
797
|
}
|
|
687
|
-
_, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"total":1,"files":[{"file_name":"ignored.png","file_path":"results/images/cover.png","download_url":"https://example.com/cover.png"}]}}`))
|
|
798
|
+
_, _ = w.Write([]byte(`{"ret":"0","errmsg":"","data":{"total":1,"files":[{"file_name":"ignored.png","file_path":"results/images/cover.png","download_url":"https://example.com/cover.png","updated_at":1779716734}]}}`))
|
|
688
799
|
}))
|
|
689
800
|
defer server.Close()
|
|
690
801
|
|
|
@@ -727,6 +838,9 @@ func TestShortDramaListThreadFile(t *testing.T) {
|
|
|
727
838
|
if file["download_url"] != "https://example.com/cover.png" {
|
|
728
839
|
t.Fatalf("download_url = %v, want returned url", file["download_url"])
|
|
729
840
|
}
|
|
841
|
+
if file["updated_at"] != float64(1779716734) {
|
|
842
|
+
t.Fatalf("updated_at = %v, want 1779716734", file["updated_at"])
|
|
843
|
+
}
|
|
730
844
|
}
|
|
731
845
|
|
|
732
846
|
func TestShortDramaListThreadFileMessageWhenPageFull(t *testing.T) {
|
|
@@ -772,6 +886,23 @@ func TestShortDramaListThreadFileRequiresThreadID(t *testing.T) {
|
|
|
772
886
|
}
|
|
773
887
|
}
|
|
774
888
|
|
|
889
|
+
func TestShortDramaListThreadFileRequiresAccessKey(t *testing.T) {
|
|
890
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
891
|
+
t.Fatal("server should not receive request without access key")
|
|
892
|
+
}))
|
|
893
|
+
defer server.Close()
|
|
894
|
+
|
|
895
|
+
var stdout, stderr bytes.Buffer
|
|
896
|
+
root := newTestRootCommandWithAccessKey(t, &stdout, &stderr, server.URL, "")
|
|
897
|
+
root.SetArgs([]string{"short-drama", "+list-thread-file", "--thread-id", "thread_123"})
|
|
898
|
+
|
|
899
|
+
err := root.Execute()
|
|
900
|
+
if err == nil {
|
|
901
|
+
t.Fatal("Execute() error = nil, want access key error")
|
|
902
|
+
}
|
|
903
|
+
assertAccessKeyGuidance(t, err)
|
|
904
|
+
}
|
|
905
|
+
|
|
775
906
|
func TestShortDramaListThreadFileRejectsPageSizeAboveMax(t *testing.T) {
|
|
776
907
|
var stdout, stderr bytes.Buffer
|
|
777
908
|
root := NewRootCommand(&stdout, &stderr)
|
|
@@ -805,6 +936,23 @@ func newTestRootCommandWithAccessKey(t *testing.T, stdout, stderr io.Writer, bas
|
|
|
805
936
|
return newRootCommand(stdout, stderr, runner)
|
|
806
937
|
}
|
|
807
938
|
|
|
939
|
+
func assertAccessKeyGuidance(t *testing.T, err error) {
|
|
940
|
+
t.Helper()
|
|
941
|
+
if err == nil {
|
|
942
|
+
t.Fatal("error = nil, want access key guidance")
|
|
943
|
+
}
|
|
944
|
+
msg := err.Error()
|
|
945
|
+
if !strings.Contains(msg, "XYQ_ACCESS_KEY is required") {
|
|
946
|
+
t.Fatalf("error = %q, want access key guidance", err)
|
|
947
|
+
}
|
|
948
|
+
if !strings.Contains(msg, "https://xyq.jianying.com/home?tab_name=home") {
|
|
949
|
+
t.Fatalf("error = %q, want access key settings URL", err)
|
|
950
|
+
}
|
|
951
|
+
if !strings.Contains(msg, `export XYQ_ACCESS_KEY="<access-key>"`) {
|
|
952
|
+
t.Fatalf("error = %q, want setup command guidance", err)
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
808
956
|
func decodeJSON(t *testing.T, data []byte) map[string]any {
|
|
809
957
|
t.Helper()
|
|
810
958
|
var got map[string]any
|
|
@@ -22,7 +22,7 @@ func (a *accessKeyAuthorizer) Inject(ctx context.Context, req *http.Request) err
|
|
|
22
22
|
return err
|
|
23
23
|
}
|
|
24
24
|
if a.accessKey == "" && req.Method == http.MethodPost {
|
|
25
|
-
return fmt.Errorf("%s
|
|
25
|
+
return fmt.Errorf("%s 缺失. 请前往小云雀官网个人设置页创建 Access Key,地址:https://xyq.jianying.com/home?tab_name=home\n配置后重试:\n export %s=\"<your-access-key>\"", config.EnvXYQAccessKey, config.EnvXYQAccessKey)
|
|
26
26
|
}
|
|
27
27
|
if a.accessKey != "" {
|
|
28
28
|
req.Header.Set("Authorization", "Bearer "+a.accessKey)
|
|
@@ -23,6 +23,7 @@ import (
|
|
|
23
23
|
type DownloadResultOptions struct {
|
|
24
24
|
URL string `json:"url"`
|
|
25
25
|
OutputPath string `json:"output_path"`
|
|
26
|
+
UpdatedAt int64 `json:"updated_at,omitempty"`
|
|
26
27
|
Workers int `json:"workers"`
|
|
27
28
|
}
|
|
28
29
|
|
|
@@ -45,8 +46,9 @@ const (
|
|
|
45
46
|
)
|
|
46
47
|
|
|
47
48
|
type downloadTask struct {
|
|
48
|
-
url
|
|
49
|
-
filepath
|
|
49
|
+
url string
|
|
50
|
+
filepath string
|
|
51
|
+
updatedAt int64
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
type downloadTaskResult struct {
|
|
@@ -67,15 +69,16 @@ func DownloadResult(ctx context.Context, opts DownloadResultOptions, runner *Run
|
|
|
67
69
|
if outputPath == "" {
|
|
68
70
|
return nil, fmt.Errorf("output_path is required")
|
|
69
71
|
}
|
|
70
|
-
// check if the output path exists and is a file
|
|
71
72
|
if info, err := os.Stat(outputPath); err == nil {
|
|
72
73
|
if info.IsDir() {
|
|
73
74
|
return nil, fmt.Errorf("output path %q is a directory", outputPath)
|
|
74
75
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
76
|
+
if shouldSkipExistingFile(info, opts.UpdatedAt) {
|
|
77
|
+
return &DownloadResultResponse{
|
|
78
|
+
OutputPath: outputPath,
|
|
79
|
+
AlreadyExist: []string{outputPath},
|
|
80
|
+
}, nil
|
|
81
|
+
}
|
|
79
82
|
} else if !os.IsNotExist(err) {
|
|
80
83
|
return nil, fmt.Errorf("stat output path: %w", err)
|
|
81
84
|
}
|
|
@@ -93,8 +96,9 @@ func DownloadResult(ctx context.Context, opts DownloadResultOptions, runner *Run
|
|
|
93
96
|
}
|
|
94
97
|
tasks := []downloadTask{
|
|
95
98
|
{
|
|
96
|
-
url:
|
|
97
|
-
filepath:
|
|
99
|
+
url: rawURL,
|
|
100
|
+
filepath: outputPath,
|
|
101
|
+
updatedAt: opts.UpdatedAt,
|
|
98
102
|
},
|
|
99
103
|
}
|
|
100
104
|
|
|
@@ -117,7 +121,7 @@ func DownloadResult(ctx context.Context, opts DownloadResultOptions, runner *Run
|
|
|
117
121
|
for task := range taskCh {
|
|
118
122
|
resultCh <- downloadTaskResult{
|
|
119
123
|
filepath: task.filepath,
|
|
120
|
-
err: downloadFileWithRetry(ctx, runner.Client, task.url, task.filepath),
|
|
124
|
+
err: downloadFileWithRetry(ctx, runner.Client, task.url, task.filepath, task.updatedAt),
|
|
121
125
|
}
|
|
122
126
|
}
|
|
123
127
|
}()
|
|
@@ -167,7 +171,14 @@ func DownloadResult(ctx context.Context, opts DownloadResultOptions, runner *Run
|
|
|
167
171
|
return result, nil
|
|
168
172
|
}
|
|
169
173
|
|
|
170
|
-
func
|
|
174
|
+
func shouldSkipExistingFile(info os.FileInfo, updatedAt int64) bool {
|
|
175
|
+
if updatedAt <= 0 {
|
|
176
|
+
return true
|
|
177
|
+
}
|
|
178
|
+
return info.ModTime().Unix() >= updatedAt
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func downloadFileWithRetry(ctx context.Context, client Client, rawURL string, targetPath string, updatedAt int64) error {
|
|
171
182
|
var lastErr error
|
|
172
183
|
for attempt := 0; attempt <= maxDownloadRetries; attempt++ {
|
|
173
184
|
if attempt > 0 {
|
|
@@ -180,7 +191,7 @@ func downloadFileWithRetry(ctx context.Context, client Client, rawURL string, ta
|
|
|
180
191
|
case <-timer.C:
|
|
181
192
|
}
|
|
182
193
|
}
|
|
183
|
-
err := downloadFile(ctx, client, rawURL, targetPath)
|
|
194
|
+
err := downloadFile(ctx, client, rawURL, targetPath, updatedAt)
|
|
184
195
|
if err == nil {
|
|
185
196
|
return nil
|
|
186
197
|
}
|
|
@@ -203,7 +214,7 @@ func isRetryableError(err error) bool {
|
|
|
203
214
|
return false
|
|
204
215
|
}
|
|
205
216
|
|
|
206
|
-
func downloadFile(ctx context.Context, client Client, rawURL string, targetPath string) error {
|
|
217
|
+
func downloadFile(ctx context.Context, client Client, rawURL string, targetPath string, updatedAt int64) error {
|
|
207
218
|
var resp *http.Response
|
|
208
219
|
if err := client.SendRequest(ctx, rawURL, nil, &resp); err != nil {
|
|
209
220
|
return err
|
|
@@ -211,6 +222,12 @@ func downloadFile(ctx context.Context, client Client, rawURL string, targetPath
|
|
|
211
222
|
defer resp.Body.Close()
|
|
212
223
|
|
|
213
224
|
tmpPath := targetPath + ".tmp"
|
|
225
|
+
tempActive := true
|
|
226
|
+
defer func() {
|
|
227
|
+
if tempActive {
|
|
228
|
+
_ = os.Remove(tmpPath)
|
|
229
|
+
}
|
|
230
|
+
}()
|
|
214
231
|
out, err := os.Create(tmpPath)
|
|
215
232
|
if err != nil {
|
|
216
233
|
return fmt.Errorf("create temp file: %w", err)
|
|
@@ -218,16 +235,18 @@ func downloadFile(ctx context.Context, client Client, rawURL string, targetPath
|
|
|
218
235
|
_, copyErr := io.Copy(out, resp.Body)
|
|
219
236
|
closeErr := out.Close()
|
|
220
237
|
if copyErr != nil {
|
|
221
|
-
_ = os.Remove(tmpPath)
|
|
222
238
|
return fmt.Errorf("write temp file: %w", copyErr)
|
|
223
239
|
}
|
|
224
240
|
if closeErr != nil {
|
|
225
|
-
_ = os.Remove(tmpPath)
|
|
226
241
|
return fmt.Errorf("close temp file: %w", closeErr)
|
|
227
242
|
}
|
|
228
243
|
if err := os.Rename(tmpPath, targetPath); err != nil {
|
|
229
|
-
_ = os.Remove(tmpPath)
|
|
230
244
|
return fmt.Errorf("replace target file: %w", err)
|
|
231
245
|
}
|
|
246
|
+
tempActive = false
|
|
247
|
+
if updatedAt > 0 {
|
|
248
|
+
modTime := time.Unix(updatedAt, 0)
|
|
249
|
+
_ = os.Chtimes(targetPath, time.Now(), modTime)
|
|
250
|
+
}
|
|
232
251
|
return nil
|
|
233
252
|
}
|
|
@@ -22,6 +22,7 @@ type ListThreadFileOptions struct {
|
|
|
22
22
|
type ThreadFile struct {
|
|
23
23
|
FilePath string `json:"file_path"`
|
|
24
24
|
DownloadURL string `json:"download_url"`
|
|
25
|
+
UpdatedAt *int64 `json:"updated_at,omitempty"`
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
// ListThreadFileResult is the JSON envelope printed by `pippit-tool-cli short-drama +list-thread-file`.
|
|
@@ -45,6 +46,7 @@ type listThreadFileResponse struct {
|
|
|
45
46
|
type threadFileResponse struct {
|
|
46
47
|
FilePath string `json:"file_path"`
|
|
47
48
|
DownloadURL string `json:"download_url"`
|
|
49
|
+
UpdatedAt *int64 `json:"updated_at,omitempty"`
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
func ListThreadFile(ctx context.Context, opts *ListThreadFileOptions, runner *Runner) (*ListThreadFileResult, error) {
|
|
@@ -81,6 +83,7 @@ func ListThreadFile(ctx context.Context, opts *ListThreadFileOptions, runner *Ru
|
|
|
81
83
|
files = append(files, &ThreadFile{
|
|
82
84
|
FilePath: threadFilePath(opts.ThreadID, file.FilePath),
|
|
83
85
|
DownloadURL: file.DownloadURL,
|
|
86
|
+
UpdatedAt: file.UpdatedAt,
|
|
84
87
|
})
|
|
85
88
|
}
|
|
86
89
|
|
package/package.json
CHANGED
|
@@ -67,6 +67,14 @@ metadata:
|
|
|
67
67
|
npx @pippit-dev/cli@latest install
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
+
部分功能需要先配置 `XYQ_ACCESS_KEY`。缺失时 CLI 会直接提示用户先创建 Access Key,此时请等待用户给与Access Key后再继续运行。
|
|
71
|
+
|
|
72
|
+
Access Key 创建地址:https://xyq.jianying.com/home?tab_name=home
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
export XYQ_ACCESS_KEY="<access-key>"
|
|
76
|
+
```
|
|
77
|
+
|
|
70
78
|
## 使用方法
|
|
71
79
|
|
|
72
80
|
### 1. 提交短剧任务
|
|
@@ -121,7 +129,8 @@ pippit-tool-cli short-drama +list-thread-file --thread-id THREAD_ID --page-num 1
|
|
|
121
129
|
```json
|
|
122
130
|
{
|
|
123
131
|
"file_path": "./{thread-id}/路径/文件名", // 文件完整路径,包含文件名
|
|
124
|
-
"download_url": "https://..." // URL
|
|
132
|
+
"download_url": "https://...", // URL
|
|
133
|
+
"updated_at": 1779716734 // 文件更新时间,Unix 秒级时间戳
|
|
125
134
|
}
|
|
126
135
|
```
|
|
127
136
|
|
|
@@ -131,10 +140,10 @@ pippit-tool-cli short-drama +list-thread-file --thread-id THREAD_ID --page-num 1
|
|
|
131
140
|
|
|
132
141
|
```bash
|
|
133
142
|
# 下载文件资源到指定文件路径
|
|
134
|
-
pippit-tool-cli short-drama +download-result --url DOWNLOAD_URL --output-path FILE_PATH
|
|
143
|
+
pippit-tool-cli short-drama +download-result --url DOWNLOAD_URL --output-path FILE_PATH --updated-at UPDATED_AT
|
|
135
144
|
```
|
|
136
145
|
|
|
137
|
-
`FILE_PATH` 必须直接使用 `+list-thread-file` 返回的完整 `file_path
|
|
146
|
+
`FILE_PATH` 必须直接使用 `+list-thread-file` 返回的完整 `file_path`,包含文件名,不要取父目录。`UPDATED_AT` 使用同一文件对象返回的 `updated_at`;如果没有 `updated_at`,可省略 `--updated-at`。`+download-result` 负责把会话产生的文件通过 URL 下载到该目标文件路径;如果目标文件已存在且本地修改时间不早于 `updated_at`,跳过下载;如果本地文件早于 `updated_at`,覆盖更新。
|
|
138
147
|
|
|
139
148
|
## 典型工作流
|
|
140
149
|
|
|
@@ -151,7 +160,7 @@ pippit-tool-cli short-drama +download-result --url DOWNLOAD_URL --output-path FI
|
|
|
151
160
|
- 如果任务仍在进行中:展示可读进展,继续查询
|
|
152
161
|
- 如果后端 Agent 提出问题:从 readable_text 中提取问题并展示,等待用户回复
|
|
153
162
|
5. 检查 `list-thread-file` 返回的 files:
|
|
154
|
-
- 对每个文件取 file_path、download_url
|
|
163
|
+
- 对每个文件取 file_path、download_url、updated_at
|
|
155
164
|
- 将 file_path 作为本地目标文件路径,包含文件名
|
|
156
165
|
- 有 download_url 的重要资产:加入本轮下载队列
|
|
157
166
|
- 不判断 file_path 在本地是否已存在,是否跳过由 +download-result 内部处理
|
|
@@ -159,6 +168,7 @@ pippit-tool-cli short-drama +download-result --url DOWNLOAD_URL --output-path FI
|
|
|
159
168
|
6. 对重要资产,立即调用 +download-result 并行下载资源:
|
|
160
169
|
- 使用第 5 步获取的 download_url 作为 --url
|
|
161
170
|
- 使用第 5 步获取的完整 file_path 作为 --output-path
|
|
171
|
+
- 如果第 5 步返回 updated_at,作为 --updated-at 传入
|
|
162
172
|
- 剧本设计、场景设计、场景图、人物角色设计、人物图、最终视频产物都属于重要资产
|
|
163
173
|
7. 查询或下载失败时,不要直接放弃;记录失败项,并在后续轮询中主动重试
|
|
164
174
|
8. 只有会话进展已处理,且已发现的重要资产均已下载或明确重试失败后,才向用户汇总最终结果
|
|
@@ -249,7 +259,8 @@ Thread: thread_...
|
|
|
249
259
|
"files": [
|
|
250
260
|
{
|
|
251
261
|
"file_path": "./{thread-id}/{file_path}/{file_name}",
|
|
252
|
-
"download_url": "https://..."
|
|
262
|
+
"download_url": "https://...",
|
|
263
|
+
"updated_at": 1779716734
|
|
253
264
|
}
|
|
254
265
|
],
|
|
255
266
|
"total": 1,
|
|
@@ -274,12 +285,12 @@ Thread: thread_...
|
|
|
274
285
|
|
|
275
286
|
### 获取会话文件
|
|
276
287
|
|
|
277
|
-
从 `+list-thread-file` 的 `files` 中逐个读取文件元信息:`file_path`、`file_name`、`download_url`。重点识别剧本设计、场景设计、场景图、人物角色设计、人物图、分集草稿、故事板、最终视频产物等重要资产。
|
|
288
|
+
从 `+list-thread-file` 的 `files` 中逐个读取文件元信息:`file_path`、`file_name`、`download_url`、`updated_at`。重点识别剧本设计、场景设计、场景图、人物角色设计、人物图、分集草稿、故事板、最终视频产物等重要资产。
|
|
278
289
|
|
|
279
290
|
```
|
|
280
291
|
1. 有download_url的重要资产
|
|
281
|
-
→ 记录该file_path和
|
|
282
|
-
→ 使用 +download-result 将URL资源下载到该file_path
|
|
292
|
+
→ 记录该file_path、URL和updated_at
|
|
293
|
+
→ 使用 +download-result 将URL资源下载到该file_path;有updated_at时传入--updated-at
|
|
283
294
|
2. 本轮total达到200
|
|
284
295
|
→ 下一轮page-num加1,继续查询新一页结果
|
|
285
296
|
3. 本轮total未达到200
|
|
@@ -293,7 +304,7 @@ Thread: thread_...
|
|
|
293
304
|
|
|
294
305
|
对带 `download_url` 的重要资产调用下载工具,可并行。重要资产必须主动下载,不要等用户再次要求,也不要在调用下载工具前先检查本地文件是否存在。
|
|
295
306
|
|
|
296
|
-
1. 调用 `pippit-tool-cli short-drama +download-result --url DOWNLOAD_URL --output-path FILE_PATH`。
|
|
307
|
+
1. 调用 `pippit-tool-cli short-drama +download-result --url DOWNLOAD_URL --output-path FILE_PATH --updated-at UPDATED_AT`;如果文件对象没有 `updated_at`,省略 `--updated-at`。
|
|
297
308
|
2. 下载完成后,向用户展示本地文件路径;如果某个文件下载失败,记录失败项并在后续轮询中重试,不阻塞已成功落盘的文件展示。
|
|
298
309
|
|
|
299
310
|
## 向用户展示内容
|
|
@@ -335,4 +346,4 @@ Thread: thread_...
|
|
|
335
346
|
- 单次创作会话中(相同 `thread_id`),`+submit-run` 只支持绑定一个剧本文件。不要在同一 `thread_id` 下重复上传并追加第二个剧本 `asset_id`;用户给多个剧本时,先让用户选择一个,或分别开启新的创作会话。
|
|
336
347
|
- `+list-thread-file` 只需要 `thread_id`;分页参数使用 `--page-num 1 --page-size 200` 起步,`total` 达到 200 时下一轮递增 `page-num`。
|
|
337
348
|
- `+list-thread-file` 和 `+download-result` 是两个不同的 CLI 指令:前者获取会话文件元信息,后者下载 URL 资源并写入到本地目标文件路径。
|
|
338
|
-
- `+download-result` 接收 `--url`、`--output-path`、`--workers`;`--output-path` 必须是包含文件名的目标文件路径。
|
|
349
|
+
- `+download-result` 接收 `--url`、`--output-path`、`--updated-at`、`--workers`;`--output-path` 必须是包含文件名的目标文件路径。
|