@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 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
- ccd86bb8dcfc02ab6d199c951d93a946a9a594436d68f096b3d400e770117101 pippit-tool-cli-0.0.21-darwin-amd64.tar.gz
2
- 7aebaaffe3ae5115e6896889316d79662591816deaac7db629029c44b692c18b pippit-tool-cli-0.0.21-darwin-arm64.tar.gz
3
- 8b7b63d04e778eedb0acddf9ad922c6db45c4e2ae98f8432dc5912fde3b11ead pippit-tool-cli-0.0.21-linux-amd64.tar.gz
4
- baf3b5fccbad34bdcfb374680536ec9da7a38b5dad988dcdf8ad82d9647c2c4d pippit-tool-cli-0.0.21-linux-arm64.tar.gz
5
- dca29c43a2d968eaef1d69236920479893c0e7351b822577645b7e9ed747ce21 pippit-tool-cli-0.0.21-windows-amd64.zip
6
- a309a1b47d1116be922d92826df75933f721cfab1f9fa99c95332611f8a9cbf9 pippit-tool-cli-0.0.21-windows-arm64.zip
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
- return map[string]string{
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
  }
@@ -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
- if !strings.Contains(err.Error(), "XYQ_ACCESS_KEY is required") {
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
- if !strings.Contains(err.Error(), "XYQ_ACCESS_KEY is required") {
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
- if !strings.Contains(err.Error(), "XYQ_ACCESS_KEY is required") {
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 is required for authenticated requests", config.EnvXYQAccessKey)
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 string
49
- filepath string
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
- return &DownloadResultResponse{
76
- OutputPath: outputPath,
77
- AlreadyExist: []string{outputPath},
78
- }, nil
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: rawURL,
97
- filepath: outputPath,
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 downloadFileWithRetry(ctx context.Context, client Client, rawURL string, targetPath string) error {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pippit-dev/cli",
3
- "version": "0.0.21",
3
+ "version": "0.0.23",
4
4
  "description": "Pippit CLI",
5
5
  "bin": {
6
6
  "pippit-tool-cli": "scripts/run.js"
@@ -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`,包含文件名,不要取父目录。`+download-result` 负责把会话产生的文件通过 URL 下载到该目标文件路径;如果目标文件已存在,跳过下载。
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和URL
282
- → 使用 +download-result 将URL资源下载到该file_path
292
+ → 记录该file_path、URLupdated_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` 必须是包含文件名的目标文件路径。