- 新增 channels/channel_models:上游 URL + AES-GCM 加密 key +
并发/权重/本地统计,模型经渠道 /v1/models 导入绑定
- internal/upstream:vivid 协议客户端(NDJSON 流 + jobId 断流重连 +
uploads 预签名 + models + balance + 错误分类)
- service/v1 重写:权重分组调度、Redis 并发槽 conc:c:、故障转移,
站内/测试/对外统一走渠道执行器
- 对外端点换成 vivid 四件套:POST /v1/generate(NDJSON+jobId 重连)、
POST /v1/uploads(SigV4 预签名 PUT)、GET /v1/models、GET /v1/balance,
错误包 {"error":{"code","message"}}
- API key 新发改 vk_+48hex;删除全部内置 provider 与 token 池
- 管理端:渠道 CRUD/拉模型/导入/余额端点,前端账号页换渠道页,
概览/文档/测试弹窗适配
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
632 lines
20 KiB
Go
632 lines
20 KiB
Go
// Package upstream is the vivid-protocol client used to talk to channel
|
|
// upstreams (other same-protocol instances). It covers the full public
|
|
// surface: POST /v1/generate (NDJSON stream with jobId reconnect), POST
|
|
// /v1/uploads (presigned reference upload), GET /v1/models, GET /v1/balance,
|
|
// plus presigned-URL result download.
|
|
package upstream
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Error classes. Dispatch decisions (retry / fail over / fail the job) key off
|
|
// these sentinels via errors.Is — KindOf maps them for log messages.
|
|
var (
|
|
// ErrAuth = channel credentials rejected (4011 / HTTP 401 / 403). The
|
|
// channel is skipped without consuming a retry budget.
|
|
ErrAuth = errors.New("upstream auth rejected")
|
|
// ErrInvalid = upstream rejected the request itself (4001 / HTTP 4xx).
|
|
// Retrying on another channel would fail identically — terminal.
|
|
ErrInvalid = errors.New("upstream rejected request")
|
|
// ErrJobFailed = the upstream ran the job and returned a failed frame.
|
|
// The frame's error text is user-safe and surfaced as-is.
|
|
ErrJobFailed = errors.New("upstream job failed")
|
|
// ErrRetryable = transient upstream failure (4293 / 5001 / 5xx / quota
|
|
// / timeout / transport). Fail over to the next channel.
|
|
ErrRetryable = errors.New("upstream temporary failure")
|
|
// ErrAmbiguous = the generate request was sent but the stream dropped
|
|
// before any jobId arrived — the job may or may not exist upstream.
|
|
// Treated as retryable for dispatch purposes.
|
|
ErrAmbiguous = errors.New("upstream submission ambiguous")
|
|
)
|
|
|
|
// Kind is the coarse dispatch classification of an upstream error.
|
|
type Kind int
|
|
|
|
const (
|
|
KindRetryable Kind = iota
|
|
KindAuth
|
|
KindInvalid
|
|
KindJobFailed
|
|
)
|
|
|
|
// KindOf classifies an error returned by this package.
|
|
func KindOf(err error) Kind {
|
|
switch {
|
|
case errors.Is(err, ErrAuth):
|
|
return KindAuth
|
|
case errors.Is(err, ErrInvalid):
|
|
return KindInvalid
|
|
case errors.Is(err, ErrJobFailed):
|
|
return KindJobFailed
|
|
default:
|
|
return KindRetryable
|
|
}
|
|
}
|
|
|
|
// Failoverable reports whether the request should be re-tried on another
|
|
// channel. ErrInvalid and ErrJobFailed are deterministic — same request, same
|
|
// result — so they are terminal.
|
|
func Failoverable(err error) bool {
|
|
return !errors.Is(err, ErrInvalid) && !errors.Is(err, ErrJobFailed)
|
|
}
|
|
|
|
// GenParams is the POST /v1/generate request body.
|
|
type GenParams struct {
|
|
Model string `json:"model"`
|
|
Prompt string `json:"prompt"`
|
|
Quality string `json:"quality,omitempty"`
|
|
Ratio string `json:"ratio,omitempty"`
|
|
Duration int64 `json:"duration,omitempty"`
|
|
Refs []string `json:"refs,omitempty"`
|
|
JobID string `json:"jobId,omitempty"`
|
|
}
|
|
|
|
// RemoteModel mirrors one item of a vivid GET /v1/models response.
|
|
type RemoteModel struct {
|
|
ID string `json:"id"`
|
|
Kind string `json:"kind"`
|
|
Qualities []string `json:"qualities"`
|
|
Duration *RemoteDuration `json:"duration,omitempty"`
|
|
// Per-kind reference caps; 0 = that kind is not accepted.
|
|
RefImages int64 `json:"refImages"`
|
|
RefVideos int64 `json:"refVideos"`
|
|
RefAudios int64 `json:"refAudios"`
|
|
// Combined reference duration caps in seconds; 0 = unlimited.
|
|
RefVideoSeconds int64 `json:"refVideoSeconds,omitempty"`
|
|
RefAudioSeconds int64 `json:"refAudioSeconds,omitempty"`
|
|
}
|
|
|
|
// RemoteDuration mirrors the duration spec of a vivid model item.
|
|
type RemoteDuration struct {
|
|
Mode string `json:"mode"` // fixed | options | range
|
|
Seconds int64 `json:"seconds,omitempty"`
|
|
Min int64 `json:"min,omitempty"`
|
|
Max int64 `json:"max,omitempty"`
|
|
Options []int64 `json:"options,omitempty"`
|
|
}
|
|
|
|
// Client is stateless; per-call baseURL/key come from the channel row.
|
|
type Client struct {
|
|
HTTP *http.Client
|
|
}
|
|
|
|
func NewClient() *Client {
|
|
return &Client{HTTP: &http.Client{Timeout: 30 * time.Minute}}
|
|
}
|
|
|
|
func (c *Client) http() *http.Client {
|
|
if c != nil && c.HTTP != nil {
|
|
return c.HTTP
|
|
}
|
|
return NewClient().HTTP
|
|
}
|
|
|
|
// maxStreamFailures caps reconnect attempts after a jobId is known — each
|
|
// reconnect re-opens /v1/generate with {jobId} and never double-charges.
|
|
const maxStreamFailures = 3
|
|
|
|
// Generate submits a vivid job and reads the NDJSON stream to a terminal
|
|
// frame, reconnecting with the reported jobId when the stream drops mid-run
|
|
// (upstream heartbeat keeps us attached; transport blips don't lose the job).
|
|
// onJobID, when non-nil, fires once the upstream assigns a job id. Returns the
|
|
// presigned result URL and the credits the upstream reports charging us.
|
|
func (c *Client) Generate(ctx context.Context, baseURL, apiKey string, p GenParams, waitSeconds int64, onJobID func(string)) (resultURL string, credits int64, err error) {
|
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
|
if baseURL == "" || apiKey == "" {
|
|
return "", 0, ErrAuth
|
|
}
|
|
var last map[string]any
|
|
failures := 0
|
|
for {
|
|
term, ferr := c.streamOnce(ctx, baseURL, apiKey, &p, waitSeconds, onJobID)
|
|
if ctx.Err() != nil {
|
|
return "", 0, ctx.Err()
|
|
}
|
|
if ferr != nil && !errors.Is(ferr, ErrRetryable) && !errors.Is(ferr, ErrAmbiguous) {
|
|
return "", 0, ferr
|
|
}
|
|
if ferr != nil {
|
|
failures++
|
|
} else {
|
|
failures = 0
|
|
}
|
|
if ferr == nil && term != nil {
|
|
status := strings.ToLower(strings.TrimSpace(str(term["status"])))
|
|
if status != "running" && status != "" {
|
|
last = term
|
|
break
|
|
}
|
|
}
|
|
if p.JobID == "" {
|
|
if ferr != nil {
|
|
return "", 0, ferr
|
|
}
|
|
return "", 0, ErrAmbiguous
|
|
}
|
|
if failures >= maxStreamFailures {
|
|
if ferr != nil {
|
|
return "", 0, ferr
|
|
}
|
|
return "", 0, fmt.Errorf("%w: stream dropped repeatedly", ErrRetryable)
|
|
}
|
|
if sleepCtx(ctx, 2*time.Second) != nil {
|
|
return "", 0, ctx.Err()
|
|
}
|
|
}
|
|
status := strings.ToLower(strings.TrimSpace(str(last["status"])))
|
|
switch status {
|
|
case "succeeded", "completed", "success":
|
|
resultURL = strings.TrimSpace(str(last["result"]))
|
|
if resultURL == "" {
|
|
return "", 0, fmt.Errorf("%w: succeeded frame missing result", ErrRetryable)
|
|
}
|
|
return resultURL, int64(num(last["creditsCharged"])), nil
|
|
default:
|
|
reason := strings.TrimSpace(str(last["error"]))
|
|
if reason == "" {
|
|
reason = "upstream job " + status
|
|
}
|
|
if isCreditError(reason) {
|
|
return "", 0, fmt.Errorf("%w: %s", ErrRetryable, clip([]byte(reason), 160))
|
|
}
|
|
// failed 帧的 error 是上游给用户看的中文文案 — 透传
|
|
return "", 0, fmt.Errorf("%w: %s", ErrJobFailed, clip([]byte(reason), 160))
|
|
}
|
|
}
|
|
|
|
// streamOnce performs one /v1/generate call and scans the NDJSON body. It
|
|
// records the upstream jobId into p.JobID as soon as a frame carries it.
|
|
func (c *Client) streamOnce(ctx context.Context, baseURL, apiKey string, p *GenParams, waitSeconds int64, onJobID func(string)) (map[string]any, error) {
|
|
raw, _ := json.Marshal(p)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/v1/generate", bytes.NewReader(raw))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if waitSeconds <= 0 {
|
|
waitSeconds = 300
|
|
}
|
|
req.Header.Set("X-Wait-Seconds", fmt.Sprintf("%d", waitSeconds))
|
|
resp, err := c.http().Do(req)
|
|
if err != nil {
|
|
if p.JobID == "" {
|
|
return nil, fmt.Errorf("%w: %s", ErrAmbiguous, sanitizeErr(err))
|
|
}
|
|
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return nil, vividError(resp.StatusCode, body)
|
|
}
|
|
var last map[string]any
|
|
sc := bufio.NewScanner(resp.Body)
|
|
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
|
for sc.Scan() {
|
|
line := bytes.TrimSpace(sc.Bytes())
|
|
if len(line) == 0 {
|
|
continue // 25s heartbeat empty line
|
|
}
|
|
var frame map[string]any
|
|
if json.Unmarshal(line, &frame) != nil {
|
|
continue
|
|
}
|
|
last = frame
|
|
if job := strings.TrimSpace(str(frame["jobId"])); job != "" && job != p.JobID {
|
|
p.JobID = job
|
|
if onJobID != nil {
|
|
onJobID(job)
|
|
}
|
|
}
|
|
if status := strings.ToLower(strings.TrimSpace(str(frame["status"]))); status == "succeeded" || status == "failed" {
|
|
return frame, nil
|
|
}
|
|
}
|
|
if err := sc.Err(); err != nil {
|
|
if p.JobID == "" {
|
|
return nil, fmt.Errorf("%w: %s", ErrAmbiguous, sanitizeErr(err))
|
|
}
|
|
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
|
}
|
|
return last, nil
|
|
}
|
|
|
|
// UploadRef runs the two-step reference upload: POST /v1/uploads {kind,mime}
|
|
// then PUT the bytes to the presigned url with the exact signed Content-Type.
|
|
// Returns the object key for refs[].
|
|
func (c *Client) UploadRef(ctx context.Context, baseURL, apiKey string, data []byte) (string, error) {
|
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
|
if baseURL == "" || apiKey == "" {
|
|
return "", ErrAuth
|
|
}
|
|
kind, mime := RefKindMime(data)
|
|
if kind == "" {
|
|
return "", fmt.Errorf("%w: 参考素材格式不支持", ErrInvalid)
|
|
}
|
|
raw, _ := json.Marshal(map[string]any{"kind": kind, "mime": mime})
|
|
up, err := c.postJSON(ctx, baseURL+"/v1/uploads", apiKey, raw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
putURL, key := UploadSession(up)
|
|
if putURL == "" || key == "" {
|
|
rawUp, _ := json.Marshal(up)
|
|
return "", fmt.Errorf("%w: upload session missing url/key (got %s)", ErrRetryable, clip(rawUp, 200))
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, putURL, bytes.NewReader(data))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", mime) // must match the signed mime exactly
|
|
resp, err := c.http().Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return "", fmt.Errorf("%w: ref upload %d %s", ErrRetryable, resp.StatusCode, clip(body, 120))
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
// UploadSession extracts the presigned PUT url + object key from a
|
|
// /v1/uploads response. Implementations vary: some return the pair at top
|
|
// level, others wrap it in {"data":{...}} / {"result":{...}}, and field
|
|
// spellings differ (upload_url / presigned_url / put_url; key / file_key /
|
|
// object_key). Search top level first, then one layer deep.
|
|
func UploadSession(up map[string]any) (putURL, key string) {
|
|
// Go map 查找区分大小写,而上游实现可能返回 PascalCase 字段;按去分隔符+
|
|
// 小写归一化匹配。
|
|
urlKeys := map[string]bool{"url": true, "uploadurl": true, "presignedurl": true, "puturl": true, "signedurl": true}
|
|
keyKeys := map[string]bool{"key": true, "filekey": true, "objectkey": true, "uploadkey": true}
|
|
norm := func(s string) string { return strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(s)) }
|
|
pick := func(m map[string]any) (string, string) {
|
|
var u, k string
|
|
for name, v := range m {
|
|
s := strings.TrimSpace(str(v))
|
|
if s == "" {
|
|
continue
|
|
}
|
|
switch {
|
|
case u == "" && urlKeys[norm(name)]:
|
|
u = s
|
|
case k == "" && keyKeys[norm(name)]:
|
|
k = s
|
|
}
|
|
}
|
|
return u, k
|
|
}
|
|
levels := []map[string]any{up}
|
|
for _, nest := range []string{"data", "result", "upload", "session", "upload_session"} {
|
|
if sub, ok := up[nest].(map[string]any); ok {
|
|
levels = append(levels, sub)
|
|
}
|
|
}
|
|
for _, m := range levels {
|
|
u, k := pick(m)
|
|
if putURL == "" {
|
|
putURL = u
|
|
}
|
|
if key == "" {
|
|
key = k
|
|
}
|
|
}
|
|
return putURL, key
|
|
}
|
|
|
|
// Models fetches the upstream's sellable model list (GET /v1/models → bare
|
|
// array of RemoteModel).
|
|
func (c *Client) Models(ctx context.Context, baseURL, apiKey string) ([]RemoteModel, error) {
|
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
|
if baseURL == "" || apiKey == "" {
|
|
return nil, ErrAuth
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v1/models", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
resp, err := c.http().Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, vividError(resp.StatusCode, body)
|
|
}
|
|
var items []RemoteModel
|
|
if err := json.Unmarshal(body, &items); err != nil {
|
|
return nil, fmt.Errorf("%w: bad /v1/models body", ErrRetryable)
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// Balance fetches the upstream balance (GET /v1/balance → {"balance":N}).
|
|
// Returns nil when the upstream does not implement the endpoint.
|
|
func (c *Client) Balance(ctx context.Context, baseURL, apiKey string) (*int64, error) {
|
|
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
|
if baseURL == "" || apiKey == "" {
|
|
return nil, ErrAuth
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v1/balance", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
resp, err := c.http().Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return nil, nil
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, vividError(resp.StatusCode, body)
|
|
}
|
|
var env struct {
|
|
Balance *int64 `json:"balance"`
|
|
}
|
|
if json.Unmarshal(body, &env) != nil || env.Balance == nil {
|
|
return nil, nil
|
|
}
|
|
return env.Balance, nil
|
|
}
|
|
|
|
// Download fetches a presigned result URL — no Authorization header.
|
|
func (c *Client) Download(ctx context.Context, url string) ([]byte, string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
resp, err := c.http().Do(req)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, "", fmt.Errorf("%w: result download %d", ErrRetryable, resp.StatusCode)
|
|
}
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if len(data) == 0 {
|
|
return nil, "", fmt.Errorf("%w: empty result", ErrRetryable)
|
|
}
|
|
ct := strings.TrimSpace(resp.Header.Get("Content-Type"))
|
|
if ct == "" || ct == "application/octet-stream" {
|
|
ct = http.DetectContentType(data)
|
|
}
|
|
return data, ct, nil
|
|
}
|
|
|
|
func (c *Client) postJSON(ctx context.Context, url, apiKey string, body []byte) (map[string]any, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := c.http().Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %s", ErrRetryable, sanitizeErr(err))
|
|
}
|
|
defer resp.Body.Close()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return nil, vividError(resp.StatusCode, raw)
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
return nil, fmt.Errorf("%w: bad response %s", ErrRetryable, clip(raw, 120))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// vividError maps the {"error":{"code":"NNNN","message":...}} envelope to our
|
|
// error classes; falls back to plain HTTP status mapping.
|
|
func vividError(status int, body []byte) error {
|
|
var e struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
code := ""
|
|
msg := clip(body, 160)
|
|
if json.Unmarshal(body, &e) == nil {
|
|
code = strings.TrimSpace(e.Error.Code)
|
|
if m := strings.TrimSpace(e.Error.Message); m != "" {
|
|
msg = clip([]byte(m), 160)
|
|
}
|
|
}
|
|
switch code {
|
|
case "4011":
|
|
return fmt.Errorf("%w: %s", ErrAuth, msg)
|
|
case "4002":
|
|
return fmt.Errorf("%w: 积分不足 %s", ErrRetryable, msg)
|
|
case "4293", "5001":
|
|
return fmt.Errorf("%w: %s", ErrRetryable, msg)
|
|
case "4001":
|
|
return fmt.Errorf("%w: %s", ErrInvalid, msg)
|
|
}
|
|
switch {
|
|
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
|
return fmt.Errorf("%w: http %d", ErrAuth, status)
|
|
case status == http.StatusTooManyRequests || status >= 500:
|
|
return fmt.Errorf("%w: http %d %s", ErrRetryable, status, msg)
|
|
default:
|
|
return fmt.Errorf("%w: http %d %s", ErrInvalid, status, msg)
|
|
}
|
|
}
|
|
|
|
// RefKindMime classifies a reference blob into the vivid "kind" (image /
|
|
// video / audio) plus a concrete mime. Magic bytes first (DetectContentType
|
|
// misses mov/m4a/ogg-audio), then sniffing, then a png default.
|
|
func RefKindMime(data []byte) (kind, mime string) {
|
|
n := len(data)
|
|
// ISOBMFF box: ftyp — mp4/mov/m4a share the container; the brand decides.
|
|
if n >= 12 && string(data[4:8]) == "ftyp" {
|
|
brand := string(data[8:12])
|
|
switch {
|
|
case strings.HasPrefix(brand, "M4A"), strings.HasPrefix(brand, "M4B"), strings.HasPrefix(brand, "F4A"), hasBMFFHandler(data, "soun") && !hasBMFFHandler(data, "vide"):
|
|
return "audio", "audio/mp4"
|
|
case brand == "qt ":
|
|
return "video", "video/quicktime"
|
|
default:
|
|
return "video", "video/mp4"
|
|
}
|
|
}
|
|
// WebM/MKV EBML
|
|
if n >= 4 && data[0] == 0x1A && data[1] == 0x45 && data[2] == 0xDF && data[3] == 0xA3 && bytes.Contains(bytes.ToLower(data[:minInt(n, 4096)]), []byte("webm")) {
|
|
return "video", "video/webm"
|
|
}
|
|
// MP3: ID3 tag or MPEG sync
|
|
if n >= 3 && string(data[:3]) == "ID3" {
|
|
return "audio", "audio/mpeg"
|
|
}
|
|
if n >= 4 && data[0] == 0xFF && (data[1]&0xE0) == 0xE0 &&
|
|
(data[1]>>3)&3 != 1 && (data[1]>>1)&3 != 0 && data[2]>>4 != 0 && data[2]>>4 != 15 && (data[2]>>2)&3 != 3 {
|
|
return "audio", "audio/mpeg"
|
|
}
|
|
// WAV / OGG
|
|
if n >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WAVE" {
|
|
return "audio", "audio/wav"
|
|
}
|
|
if n >= 27 && string(data[:4]) == "OggS" && (bytes.Contains(data[:minInt(n, 4096)], []byte("OpusHead")) ||
|
|
bytes.Contains(data[:minInt(n, 4096)], []byte("vorbis")) || bytes.Contains(data[:minInt(n, 4096)], []byte("Speex ")) ||
|
|
bytes.Contains(data[:minInt(n, 4096)], []byte("fLaC"))) {
|
|
return "audio", "audio/ogg"
|
|
}
|
|
if m := http.DetectContentType(data); m == "image/png" || m == "image/jpeg" || m == "image/webp" {
|
|
return "image", m
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
func hasBMFFHandler(data []byte, handler string) bool {
|
|
data = data[:minInt(len(data), 4096)]
|
|
for offset := 0; offset < len(data); {
|
|
i := bytes.Index(data[offset:], []byte("hdlr"))
|
|
if i < 0 {
|
|
return false
|
|
}
|
|
i += offset
|
|
if i+16 <= len(data) && string(data[i+12:i+16]) == handler {
|
|
return true
|
|
}
|
|
offset = i + 4
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isCreditError(s string) bool {
|
|
m := strings.ToLower(s)
|
|
return strings.Contains(m, "credit") || strings.Contains(m, "quota") ||
|
|
strings.Contains(m, "insufficient") || strings.Contains(s, "积分不足") ||
|
|
strings.Contains(s, "余额")
|
|
}
|
|
|
|
func str(v any) string {
|
|
switch t := v.(type) {
|
|
case string:
|
|
return t
|
|
case fmt.Stringer:
|
|
return t.String()
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func num(v any) float64 {
|
|
switch t := v.(type) {
|
|
case float64:
|
|
return t
|
|
case int64:
|
|
return float64(t)
|
|
case json.Number:
|
|
f, _ := t.Float64()
|
|
return f
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func clip(b []byte, n int) string {
|
|
s := strings.TrimSpace(string(b))
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "…"
|
|
}
|
|
|
|
func minInt(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// sanitizeErr strips the upstream URL/host from a network error so a channel's
|
|
// private upstream URL never leaks into the event log / API response.
|
|
func sanitizeErr(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
s := err.Error()
|
|
switch {
|
|
case strings.Contains(s, "context deadline exceeded"), strings.Contains(s, "Client.Timeout"), strings.Contains(s, "timeout"):
|
|
return "request timeout"
|
|
case strings.Contains(s, "connection refused"):
|
|
return "connection refused"
|
|
case strings.Contains(s, "no such host"), strings.Contains(s, "dial tcp"), strings.Contains(s, "lookup "):
|
|
return "cannot reach upstream"
|
|
case strings.Contains(s, "tls"), strings.Contains(s, "TLS"), strings.Contains(s, "certificate"):
|
|
return "TLS error"
|
|
case strings.Contains(s, "EOF"), strings.Contains(s, "reset by peer"), strings.Contains(s, "broken pipe"):
|
|
return "connection reset"
|
|
}
|
|
var ue *url.Error
|
|
if errors.As(err, &ue) {
|
|
return strings.ToLower(ue.Op) + " upstream failed"
|
|
}
|
|
return "upstream request failed"
|
|
}
|
|
|
|
func sleepCtx(ctx context.Context, d time.Duration) error {
|
|
t := time.NewTimer(d)
|
|
defer t.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-t.C:
|
|
return nil
|
|
}
|
|
}
|