@pippit-dev/cli 0.0.1
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/LICENSE +21 -0
- package/README.md +50 -0
- package/cmd/auth/auth.go +144 -0
- package/cmd/root.go +42 -0
- package/cmd/short_drama/short_drama.go +196 -0
- package/cmd/short_drama_test.go +587 -0
- package/cmd/update/update.go +124 -0
- package/internal/auth/manager.go +146 -0
- package/internal/common/access_key.go +29 -0
- package/internal/common/client.go +137 -0
- package/internal/common/download_results.go +243 -0
- package/internal/common/list_thread_file.go +104 -0
- package/internal/common/runner.go +21 -0
- package/internal/common/upload_file.go +49 -0
- package/internal/config/config.go +72 -0
- package/internal/config/config_test.go +49 -0
- package/internal/short_drama/get_thread.go +137 -0
- package/internal/short_drama/mock.go +47 -0
- package/internal/short_drama/submit_run.go +81 -0
- package/package.json +45 -0
- package/scripts/install-wizard.js +71 -0
- package/scripts/install.js +170 -0
- package/scripts/platform.js +28 -0
- package/scripts/run.js +62 -0
- package/scripts/skills.js +32 -0
- package/skills/short-drama/SKILL.md +280 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
package auth
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"net/http"
|
|
8
|
+
"sync"
|
|
9
|
+
"time"
|
|
10
|
+
|
|
11
|
+
"code.byted.org/passport/auth_client/go/authsdk"
|
|
12
|
+
|
|
13
|
+
"github.com/Pippit-dev/pippit-cli/internal/config"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
type Authorizer interface {
|
|
17
|
+
Refresh(ctx context.Context, ensureTTL time.Duration) error
|
|
18
|
+
Inject(ctx context.Context, req *http.Request) error
|
|
19
|
+
NewLoginFlow(ctx context.Context) (*LoginFlow, error)
|
|
20
|
+
CheckLogin(ctx context.Context, deviceCode string) (*State, error)
|
|
21
|
+
State(ctx context.Context) (*State, error)
|
|
22
|
+
Logout(ctx context.Context) error
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type OAuthManager struct {
|
|
26
|
+
cfg *config.Config
|
|
27
|
+
mu sync.Mutex
|
|
28
|
+
client *authsdk.Client
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type LoginFlow struct {
|
|
32
|
+
DeviceCode string `json:"device_code"`
|
|
33
|
+
UserCode string `json:"user_code"`
|
|
34
|
+
VerificationURI string `json:"verification_uri"`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type State struct {
|
|
38
|
+
LoggedIn bool `json:"logged_in"`
|
|
39
|
+
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func NewManager(cfg *config.Config) *OAuthManager {
|
|
43
|
+
return &OAuthManager{cfg: cfg}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func (m *OAuthManager) NewLoginFlow(ctx context.Context) (*LoginFlow, error) {
|
|
47
|
+
client, err := m.clientInstance()
|
|
48
|
+
if err != nil {
|
|
49
|
+
return nil, err
|
|
50
|
+
}
|
|
51
|
+
flow, err := client.Authenticator().NewLoginFlow(ctx)
|
|
52
|
+
if err != nil {
|
|
53
|
+
return nil, err
|
|
54
|
+
}
|
|
55
|
+
return &LoginFlow{
|
|
56
|
+
DeviceCode: flow.DeviceCode,
|
|
57
|
+
UserCode: flow.UserCode,
|
|
58
|
+
VerificationURI: flow.VerificationURI,
|
|
59
|
+
}, nil
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func (m *OAuthManager) CheckLogin(ctx context.Context, deviceCode string) (*State, error) {
|
|
63
|
+
client, err := m.clientInstance()
|
|
64
|
+
if err != nil {
|
|
65
|
+
return nil, err
|
|
66
|
+
}
|
|
67
|
+
state, err := client.Authenticator().CheckLogin(ctx, deviceCode)
|
|
68
|
+
if err != nil {
|
|
69
|
+
return nil, err
|
|
70
|
+
}
|
|
71
|
+
return authState(state), nil
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func (m *OAuthManager) State(ctx context.Context) (*State, error) {
|
|
75
|
+
client, err := m.clientInstance()
|
|
76
|
+
if err != nil {
|
|
77
|
+
return nil, err
|
|
78
|
+
}
|
|
79
|
+
state, err := client.Authorizer().State(ctx)
|
|
80
|
+
if err != nil {
|
|
81
|
+
return nil, err
|
|
82
|
+
}
|
|
83
|
+
return authState(state), nil
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func (m *OAuthManager) Refresh(ctx context.Context, ensureTTL time.Duration) error {
|
|
87
|
+
client, err := m.clientInstance()
|
|
88
|
+
if err != nil {
|
|
89
|
+
return err
|
|
90
|
+
}
|
|
91
|
+
_, err = client.Authorizer().Refresh(ctx, ensureTTL)
|
|
92
|
+
return err
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
func (m *OAuthManager) Inject(ctx context.Context, req *http.Request) error {
|
|
96
|
+
client, err := m.clientInstance()
|
|
97
|
+
if err != nil {
|
|
98
|
+
return err
|
|
99
|
+
}
|
|
100
|
+
return client.Authorizer().Inject(ctx, req)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func (m *OAuthManager) Logout(ctx context.Context) error {
|
|
104
|
+
client, err := m.clientInstance()
|
|
105
|
+
if err != nil {
|
|
106
|
+
return err
|
|
107
|
+
}
|
|
108
|
+
return client.Authorizer().Logout(ctx)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
func IsLoginPending(err error) bool {
|
|
112
|
+
return errors.Is(err, authsdk.ErrLoginPending)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func (m *OAuthManager) clientInstance() (*authsdk.Client, error) {
|
|
116
|
+
m.mu.Lock()
|
|
117
|
+
defer m.mu.Unlock()
|
|
118
|
+
if m.client != nil {
|
|
119
|
+
return m.client, nil
|
|
120
|
+
}
|
|
121
|
+
if m.cfg == nil || m.cfg.OAuth == nil {
|
|
122
|
+
return nil, errors.New("oauth config is required")
|
|
123
|
+
}
|
|
124
|
+
oauth := m.cfg.OAuth
|
|
125
|
+
client, err := authsdk.NewClient(authsdk.Config{
|
|
126
|
+
ClientKey: oauth.ClientKey,
|
|
127
|
+
BaseURL: oauth.BaseURL,
|
|
128
|
+
StoreServiceName: oauth.StoreServiceName,
|
|
129
|
+
Scopes: oauth.Scopes,
|
|
130
|
+
})
|
|
131
|
+
if err != nil {
|
|
132
|
+
return nil, fmt.Errorf("initialize oauth client: %w", err)
|
|
133
|
+
}
|
|
134
|
+
m.client = client
|
|
135
|
+
return client, nil
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func authState(state *authsdk.AuthStateView) *State {
|
|
139
|
+
if state == nil {
|
|
140
|
+
return &State{}
|
|
141
|
+
}
|
|
142
|
+
return &State{
|
|
143
|
+
LoggedIn: state.LoggedIn,
|
|
144
|
+
ExpiresAt: state.ExpiresAt,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
package common
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"net/http"
|
|
7
|
+
"strings"
|
|
8
|
+
|
|
9
|
+
"github.com/Pippit-dev/pippit-cli/internal/config"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
type accessKeyAuthorizer struct {
|
|
13
|
+
accessKey string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func NewAccessKeyAuthorizer(accessKey string) RequestAuthorizer {
|
|
17
|
+
return &accessKeyAuthorizer{accessKey: strings.TrimSpace(accessKey)}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func (a *accessKeyAuthorizer) Inject(ctx context.Context, req *http.Request) error {
|
|
21
|
+
if err := ctx.Err(); err != nil {
|
|
22
|
+
return err
|
|
23
|
+
}
|
|
24
|
+
if a.accessKey == "" {
|
|
25
|
+
return fmt.Errorf("%s is required for authenticated requests", config.EnvXYQAccessKey)
|
|
26
|
+
}
|
|
27
|
+
req.Header.Set("Authorization", "Bearer "+a.accessKey)
|
|
28
|
+
return nil
|
|
29
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
package common
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"context"
|
|
6
|
+
"fmt"
|
|
7
|
+
"io"
|
|
8
|
+
"net/http"
|
|
9
|
+
"net/url"
|
|
10
|
+
"strings"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
"github.com/bytedance/sonic"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
type Client interface {
|
|
17
|
+
SendRequest(ctx context.Context, path string, body any, out any) error
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type RequestAuthorizer interface {
|
|
21
|
+
Inject(ctx context.Context, req *http.Request) error
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type httpClient struct {
|
|
25
|
+
baseURL string
|
|
26
|
+
httpClient *http.Client
|
|
27
|
+
headers http.Header
|
|
28
|
+
authorizer RequestAuthorizer
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func NewHTTPClient(baseURL string, timeout time.Duration, authorizer RequestAuthorizer) Client {
|
|
32
|
+
return &httpClient{
|
|
33
|
+
baseURL: strings.TrimRight(baseURL, "/"),
|
|
34
|
+
httpClient: &http.Client{
|
|
35
|
+
Timeout: timeout,
|
|
36
|
+
},
|
|
37
|
+
headers: make(http.Header),
|
|
38
|
+
authorizer: authorizer,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func (c *httpClient) SendRequest(ctx context.Context, path string, body any, out any) error {
|
|
43
|
+
if c.authorizer == nil {
|
|
44
|
+
return fmt.Errorf("authorized request requires authorizer")
|
|
45
|
+
}
|
|
46
|
+
req, err := c.newJSONRequest(ctx, path, body)
|
|
47
|
+
if err != nil {
|
|
48
|
+
return err
|
|
49
|
+
}
|
|
50
|
+
if err := c.authorizer.Inject(ctx, req); err != nil {
|
|
51
|
+
return fmt.Errorf("inject auth headers: %w", err)
|
|
52
|
+
}
|
|
53
|
+
return c.do(req, out)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
func (c *httpClient) newJSONRequest(ctx context.Context, path string, body any) (*http.Request, error) {
|
|
57
|
+
reqURL, err := c.resolveURL(path, nil)
|
|
58
|
+
if err != nil {
|
|
59
|
+
return nil, err
|
|
60
|
+
}
|
|
61
|
+
var reader io.Reader
|
|
62
|
+
if body != nil {
|
|
63
|
+
payload, err := sonic.Marshal(body)
|
|
64
|
+
if err != nil {
|
|
65
|
+
return nil, fmt.Errorf("encode POST body: %w", err)
|
|
66
|
+
}
|
|
67
|
+
reader = bytes.NewReader(payload)
|
|
68
|
+
}
|
|
69
|
+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, reader)
|
|
70
|
+
if err != nil {
|
|
71
|
+
return nil, fmt.Errorf("build POST request: %w", err)
|
|
72
|
+
}
|
|
73
|
+
if body != nil {
|
|
74
|
+
req.Header.Set("Content-Type", "application/json")
|
|
75
|
+
}
|
|
76
|
+
return req, nil
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func (c *httpClient) do(req *http.Request, out any) error {
|
|
80
|
+
for k, values := range c.headers {
|
|
81
|
+
for _, v := range values {
|
|
82
|
+
req.Header.Add(k, v)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
req.Header.Set("Accept", "application/json")
|
|
86
|
+
|
|
87
|
+
resp, err := c.httpClient.Do(req)
|
|
88
|
+
if err != nil {
|
|
89
|
+
return fmt.Errorf("%s %s failed: %w", req.Method, req.URL.String(), err)
|
|
90
|
+
}
|
|
91
|
+
defer resp.Body.Close()
|
|
92
|
+
|
|
93
|
+
data, err := io.ReadAll(resp.Body)
|
|
94
|
+
if err != nil {
|
|
95
|
+
return fmt.Errorf("read response body: %w", err)
|
|
96
|
+
}
|
|
97
|
+
if resp.StatusCode >= 400 {
|
|
98
|
+
msg := strings.TrimSpace(string(data))
|
|
99
|
+
if msg == "" {
|
|
100
|
+
msg = http.StatusText(resp.StatusCode)
|
|
101
|
+
}
|
|
102
|
+
return fmt.Errorf("%s %s returned HTTP %d: %s", req.Method, req.URL.String(), resp.StatusCode, msg)
|
|
103
|
+
}
|
|
104
|
+
if out == nil || len(data) == 0 {
|
|
105
|
+
return nil
|
|
106
|
+
}
|
|
107
|
+
if err := sonic.Unmarshal(data, out); err != nil {
|
|
108
|
+
return fmt.Errorf("decode response body: %w", err)
|
|
109
|
+
}
|
|
110
|
+
return nil
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func (c *httpClient) resolveURL(path string, query map[string]string) (string, error) {
|
|
114
|
+
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
|
115
|
+
return appendQuery(path, query)
|
|
116
|
+
}
|
|
117
|
+
if c.baseURL == "" {
|
|
118
|
+
return "", fmt.Errorf("base URL is required for relative path %q", path)
|
|
119
|
+
}
|
|
120
|
+
if !strings.HasPrefix(path, "/") {
|
|
121
|
+
path = "/" + path
|
|
122
|
+
}
|
|
123
|
+
return appendQuery(c.baseURL+path, query)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
func appendQuery(raw string, query map[string]string) (string, error) {
|
|
127
|
+
u, err := url.Parse(raw)
|
|
128
|
+
if err != nil {
|
|
129
|
+
return "", fmt.Errorf("parse URL: %w", err)
|
|
130
|
+
}
|
|
131
|
+
values := u.Query()
|
|
132
|
+
for k, v := range query {
|
|
133
|
+
values.Set(k, v)
|
|
134
|
+
}
|
|
135
|
+
u.RawQuery = values.Encode()
|
|
136
|
+
return u.String(), nil
|
|
137
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
package common
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"io"
|
|
8
|
+
"net/http"
|
|
9
|
+
"net/url"
|
|
10
|
+
"os"
|
|
11
|
+
"path/filepath"
|
|
12
|
+
"sort"
|
|
13
|
+
"strings"
|
|
14
|
+
"sync"
|
|
15
|
+
"time"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
// DownloadResultOptions is the command-facing shape for downloading one result URL.
|
|
19
|
+
type DownloadResultOptions struct {
|
|
20
|
+
URL string `json:"url"`
|
|
21
|
+
OutputDir string `json:"output_dir"`
|
|
22
|
+
Workers int `json:"workers"`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type DownloadResultError struct {
|
|
26
|
+
File string `json:"file"`
|
|
27
|
+
Error string `json:"error"`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// DownloadResultResponse is the JSON envelope printed by `pippit-cli short-drama +download-result`.
|
|
31
|
+
type DownloadResultResponse struct {
|
|
32
|
+
OutputDir string `json:"output_dir"`
|
|
33
|
+
Downloaded []string `json:"downloaded"`
|
|
34
|
+
AlreadyExist []string `json:"already_exist,omitempty"`
|
|
35
|
+
Total int `json:"total"`
|
|
36
|
+
Errors []*DownloadResultError `json:"errors,omitempty"`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const (
|
|
40
|
+
maxDownloadRetries = 3
|
|
41
|
+
retryBaseDelay = 500 * time.Millisecond
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
type downloadTask struct {
|
|
45
|
+
url string
|
|
46
|
+
filepath string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type downloadTaskResult struct {
|
|
50
|
+
filepath string
|
|
51
|
+
err error
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func DownloadResult(ctx context.Context, opts DownloadResultOptions, _ *Runner) (*DownloadResultResponse, error) {
|
|
55
|
+
if err := ctx.Err(); err != nil {
|
|
56
|
+
return nil, err
|
|
57
|
+
}
|
|
58
|
+
rawURL := strings.TrimSpace(opts.URL)
|
|
59
|
+
if rawURL == "" {
|
|
60
|
+
return nil, fmt.Errorf("download url is required")
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
outputPath := strings.TrimSpace(opts.OutputDir)
|
|
64
|
+
if outputPath == "" {
|
|
65
|
+
return nil, fmt.Errorf("output_dir is required")
|
|
66
|
+
}
|
|
67
|
+
// check if the output path exists and is a file
|
|
68
|
+
if info, err := os.Stat(outputPath); err == nil {
|
|
69
|
+
if info.IsDir() {
|
|
70
|
+
return nil, fmt.Errorf("output path %q is a directory", outputPath)
|
|
71
|
+
}
|
|
72
|
+
return &DownloadResultResponse{
|
|
73
|
+
OutputDir: outputPath,
|
|
74
|
+
AlreadyExist: []string{outputPath},
|
|
75
|
+
Total: 0,
|
|
76
|
+
}, nil
|
|
77
|
+
} else if !os.IsNotExist(err) {
|
|
78
|
+
return nil, fmt.Errorf("stat output path: %w", err)
|
|
79
|
+
}
|
|
80
|
+
outputDir := filepath.Dir(outputPath)
|
|
81
|
+
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
|
82
|
+
return nil, fmt.Errorf("create output dir: %w", err)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
parsed, err := url.Parse(rawURL)
|
|
86
|
+
if err != nil {
|
|
87
|
+
return nil, fmt.Errorf("invalid url %q: %w", rawURL, err)
|
|
88
|
+
}
|
|
89
|
+
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
90
|
+
return nil, fmt.Errorf("invalid url scheme %q in %q, only http and https are allowed", parsed.Scheme, rawURL)
|
|
91
|
+
}
|
|
92
|
+
tasks := []downloadTask{
|
|
93
|
+
{
|
|
94
|
+
url: rawURL,
|
|
95
|
+
filepath: outputPath,
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
workers := opts.Workers
|
|
100
|
+
if workers <= 0 {
|
|
101
|
+
workers = 5
|
|
102
|
+
}
|
|
103
|
+
if workers > len(tasks) {
|
|
104
|
+
workers = len(tasks)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
client := &http.Client{Timeout: 600 * time.Second}
|
|
108
|
+
taskCh := make(chan downloadTask)
|
|
109
|
+
resultCh := make(chan downloadTaskResult, len(tasks))
|
|
110
|
+
|
|
111
|
+
var wg sync.WaitGroup
|
|
112
|
+
for i := 0; i < workers; i++ {
|
|
113
|
+
wg.Add(1)
|
|
114
|
+
go func() {
|
|
115
|
+
defer wg.Done()
|
|
116
|
+
for task := range taskCh {
|
|
117
|
+
resultCh <- downloadTaskResult{
|
|
118
|
+
filepath: task.filepath,
|
|
119
|
+
err: downloadFileWithRetry(ctx, client, task.url, task.filepath),
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
go func() {
|
|
126
|
+
defer close(taskCh)
|
|
127
|
+
for _, task := range tasks {
|
|
128
|
+
select {
|
|
129
|
+
case <-ctx.Done():
|
|
130
|
+
return
|
|
131
|
+
case taskCh <- task:
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}()
|
|
135
|
+
|
|
136
|
+
go func() {
|
|
137
|
+
wg.Wait()
|
|
138
|
+
close(resultCh)
|
|
139
|
+
}()
|
|
140
|
+
|
|
141
|
+
downloaded := make([]string, 0, len(tasks))
|
|
142
|
+
errorList := make([]*DownloadResultError, 0)
|
|
143
|
+
for result := range resultCh {
|
|
144
|
+
if result.err != nil {
|
|
145
|
+
errorList = append(errorList, &DownloadResultError{
|
|
146
|
+
File: result.filepath,
|
|
147
|
+
Error: result.err.Error(),
|
|
148
|
+
})
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
downloaded = append(downloaded, result.filepath)
|
|
152
|
+
}
|
|
153
|
+
sort.Strings(downloaded)
|
|
154
|
+
sort.Slice(errorList, func(i, j int) bool {
|
|
155
|
+
return errorList[i].File < errorList[j].File
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
result := &DownloadResultResponse{
|
|
159
|
+
OutputDir: outputPath,
|
|
160
|
+
Downloaded: downloaded,
|
|
161
|
+
Total: len(downloaded),
|
|
162
|
+
Errors: errorList,
|
|
163
|
+
}
|
|
164
|
+
if len(downloaded) == 0 && len(errorList) > 0 {
|
|
165
|
+
return result, fmt.Errorf("all %d download(s) failed", len(errorList))
|
|
166
|
+
}
|
|
167
|
+
return result, nil
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
func downloadFileWithRetry(ctx context.Context, client *http.Client, rawURL string, targetPath string) error {
|
|
171
|
+
var lastErr error
|
|
172
|
+
for attempt := 0; attempt <= maxDownloadRetries; attempt++ {
|
|
173
|
+
if attempt > 0 {
|
|
174
|
+
delay := retryBaseDelay * time.Duration(1<<(attempt-1))
|
|
175
|
+
timer := time.NewTimer(delay)
|
|
176
|
+
select {
|
|
177
|
+
case <-ctx.Done():
|
|
178
|
+
timer.Stop()
|
|
179
|
+
return ctx.Err()
|
|
180
|
+
case <-timer.C:
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
err := downloadFile(ctx, client, rawURL, targetPath)
|
|
184
|
+
if err == nil {
|
|
185
|
+
return nil
|
|
186
|
+
}
|
|
187
|
+
lastErr = err
|
|
188
|
+
if !isRetryableError(err) {
|
|
189
|
+
return err
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return fmt.Errorf("failed after %d retries: %w", maxDownloadRetries, lastErr)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
func isRetryableError(err error) bool {
|
|
196
|
+
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
|
197
|
+
return false
|
|
198
|
+
}
|
|
199
|
+
var urlErr *url.Error
|
|
200
|
+
if errors.As(err, &urlErr) {
|
|
201
|
+
return urlErr.Timeout() || urlErr.Temporary()
|
|
202
|
+
}
|
|
203
|
+
return false
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
func downloadFile(ctx context.Context, client *http.Client, rawURL string, targetPath string) error {
|
|
207
|
+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
208
|
+
if err != nil {
|
|
209
|
+
return fmt.Errorf("build download request: %w", err)
|
|
210
|
+
}
|
|
211
|
+
req.Header.Set("User-Agent", "Pippit-CLI/1.0")
|
|
212
|
+
|
|
213
|
+
resp, err := client.Do(req)
|
|
214
|
+
if err != nil {
|
|
215
|
+
return err
|
|
216
|
+
}
|
|
217
|
+
defer resp.Body.Close()
|
|
218
|
+
|
|
219
|
+
if resp.StatusCode >= 400 {
|
|
220
|
+
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, http.StatusText(resp.StatusCode))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
tmpPath := targetPath + ".tmp"
|
|
224
|
+
out, err := os.Create(tmpPath)
|
|
225
|
+
if err != nil {
|
|
226
|
+
return fmt.Errorf("create temp file: %w", err)
|
|
227
|
+
}
|
|
228
|
+
_, copyErr := io.Copy(out, resp.Body)
|
|
229
|
+
closeErr := out.Close()
|
|
230
|
+
if copyErr != nil {
|
|
231
|
+
_ = os.Remove(tmpPath)
|
|
232
|
+
return fmt.Errorf("write temp file: %w", copyErr)
|
|
233
|
+
}
|
|
234
|
+
if closeErr != nil {
|
|
235
|
+
_ = os.Remove(tmpPath)
|
|
236
|
+
return fmt.Errorf("close temp file: %w", closeErr)
|
|
237
|
+
}
|
|
238
|
+
if err := os.Rename(tmpPath, targetPath); err != nil {
|
|
239
|
+
_ = os.Remove(tmpPath)
|
|
240
|
+
return fmt.Errorf("replace target file: %w", err)
|
|
241
|
+
}
|
|
242
|
+
return nil
|
|
243
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
package common
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"strings"
|
|
8
|
+
|
|
9
|
+
"github.com/Pippit-dev/pippit-cli/internal/config"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// ListThreadFileOptions is the command-facing request shape for listing thread files.
|
|
13
|
+
type ListThreadFileOptions struct {
|
|
14
|
+
ThreadID string `json:"thread_id"`
|
|
15
|
+
PageSize int `json:"page_size,omitempty"`
|
|
16
|
+
PageNum int `json:"page_num,omitempty"`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ThreadFile is a file entry in a thread.
|
|
20
|
+
type ThreadFile struct {
|
|
21
|
+
FilePath string `json:"file_path"`
|
|
22
|
+
DownloadURL string `json:"download_url"`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ListThreadFileResult is the JSON envelope printed by `pippit-cli short-drama +list-thread-file`.
|
|
26
|
+
type ListThreadFileResult struct {
|
|
27
|
+
Files []*ThreadFile `json:"files"`
|
|
28
|
+
Total int64 `json:"total"`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type listThreadFileResponse struct {
|
|
32
|
+
Ret string `json:"ret"`
|
|
33
|
+
Errmsg string `json:"errmsg"`
|
|
34
|
+
SvrTime int64 `json:"svr_time"`
|
|
35
|
+
LogID string `json:"log_id"`
|
|
36
|
+
Data struct {
|
|
37
|
+
Files []*threadFileResponse `json:"files"`
|
|
38
|
+
Total int64 `json:"total"`
|
|
39
|
+
} `json:"data"`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type threadFileResponse struct {
|
|
43
|
+
FilePath string `json:"file_path"`
|
|
44
|
+
DownloadURL string `json:"download_url"`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func ListThreadFile(ctx context.Context, opts *ListThreadFileOptions, runner *Runner) (*ListThreadFileResult, error) {
|
|
48
|
+
if runner == nil || runner.Client == nil {
|
|
49
|
+
return nil, fmt.Errorf("list_thread_file runner client is required")
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
body := map[string]any{
|
|
53
|
+
"thread_id": opts.ThreadID,
|
|
54
|
+
}
|
|
55
|
+
if opts.PageSize > 0 {
|
|
56
|
+
body["page_size"] = opts.PageSize
|
|
57
|
+
}
|
|
58
|
+
if opts.PageNum > 0 {
|
|
59
|
+
body["page_num"] = opts.PageNum
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
var resp listThreadFileResponse
|
|
63
|
+
if err := runner.Client.SendRequest(ctx, listThreadFilePath(runner), body, &resp); err != nil {
|
|
64
|
+
return nil, fmt.Errorf("list_thread_file request failed: %w", err)
|
|
65
|
+
}
|
|
66
|
+
if resp.Ret != "0" {
|
|
67
|
+
if resp.Errmsg == "" {
|
|
68
|
+
resp.Errmsg = "unknown error"
|
|
69
|
+
}
|
|
70
|
+
return nil, fmt.Errorf("list_thread_file failed: ret=%s errmsg=%s", resp.Ret, resp.Errmsg)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
files := make([]*ThreadFile, 0, len(resp.Data.Files))
|
|
74
|
+
for _, file := range resp.Data.Files {
|
|
75
|
+
if file == nil {
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
files = append(files, &ThreadFile{
|
|
79
|
+
FilePath: threadFilePath(opts.ThreadID, file.FilePath),
|
|
80
|
+
DownloadURL: file.DownloadURL,
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return &ListThreadFileResult{
|
|
85
|
+
Files: files,
|
|
86
|
+
Total: resp.Data.Total,
|
|
87
|
+
}, nil
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
func threadFilePath(threadID string, filePath string) string {
|
|
91
|
+
parts := []string{strings.TrimSpace(threadID)}
|
|
92
|
+
trimmedFilePath := strings.Trim(strings.TrimSpace(filePath), `/\`)
|
|
93
|
+
if trimmedFilePath != "" {
|
|
94
|
+
parts = append(parts, trimmedFilePath)
|
|
95
|
+
}
|
|
96
|
+
return "." + string(filepath.Separator) + filepath.Join(parts...)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func listThreadFilePath(runner *Runner) string {
|
|
100
|
+
if runner != nil && runner.Config != nil && runner.Config.Paths != nil && runner.Config.Paths.ListThreadFile != "" {
|
|
101
|
+
return runner.Config.Paths.ListThreadFile
|
|
102
|
+
}
|
|
103
|
+
return config.ListThreadFilePath
|
|
104
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
package common
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"github.com/Pippit-dev/pippit-cli/internal/auth"
|
|
5
|
+
"github.com/Pippit-dev/pippit-cli/internal/config"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
// Runner carries runtime dependencies for command execution.
|
|
9
|
+
type Runner struct {
|
|
10
|
+
Config *config.Config
|
|
11
|
+
Client Client
|
|
12
|
+
AuthAuthorizer auth.Authorizer
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
func NewRunner(cfg *config.Config, client Client, authAuthorizer auth.Authorizer) *Runner {
|
|
16
|
+
return &Runner{
|
|
17
|
+
Config: cfg,
|
|
18
|
+
Client: client,
|
|
19
|
+
AuthAuthorizer: authAuthorizer,
|
|
20
|
+
}
|
|
21
|
+
}
|