Initial commit
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
package laosiji
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// LaosijiDomains 老司机上传凭证缓存。由 web server 初始化。
|
||||
LaosijiDomains *Domains
|
||||
// LaosijiClient 老司机业务 API 客户端。由 web server 初始化。
|
||||
LaosijiClient *Client
|
||||
)
|
||||
|
||||
// Client 老司机「业务 API」客户端(区别于上传站 upload_url)。
|
||||
type Client struct {
|
||||
baseURL string
|
||||
appid string
|
||||
appkey string
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
// New 创建业务 API 客户端。baseURL 含 /lsjapi 前缀。
|
||||
func New(baseURL, appid, appkey string) *Client {
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
appid: appid,
|
||||
appkey: appkey,
|
||||
hc: &http.Client{Timeout: 40 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// SetHTTPClient 替换底层 http.Client(测试/自定义超时用)。
|
||||
func (c *Client) SetHTTPClient(hc *http.Client) { c.hc = hc }
|
||||
|
||||
type apiResp struct {
|
||||
Status string `json:"status"`
|
||||
Data string `json:"data"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// ErrEmptyData status=y 但 data 为空。
|
||||
var ErrEmptyData = errors.New("laosiji: empty response data")
|
||||
|
||||
// Call 发起一次业务 API 调用。
|
||||
func (c *Client) Call(ctx context.Context, path string, req, out interface{}) error {
|
||||
reqJSON := []byte("{}")
|
||||
if req != nil {
|
||||
b, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("laosiji: marshal req: %w", err)
|
||||
}
|
||||
reqJSON = b
|
||||
}
|
||||
encBody, err := EncryptBase64(reqJSON, c.appkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("laosiji: encrypt req: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, strings.NewReader(encBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpReq.Header.Set("appid", c.appid)
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.hc.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("laosiji: http do: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("laosiji: read resp: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("laosiji: http %d: %s", resp.StatusCode, truncate(body, 200))
|
||||
}
|
||||
|
||||
var outer apiResp
|
||||
if err := json.Unmarshal(body, &outer); err != nil {
|
||||
return fmt.Errorf("laosiji: unmarshal outer: %w (body=%s)", err, truncate(body, 200))
|
||||
}
|
||||
if outer.Status != "y" {
|
||||
return fmt.Errorf("laosiji: api status=%q error=%q", outer.Status, outer.Error)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if outer.Data == "" {
|
||||
return ErrEmptyData
|
||||
}
|
||||
plain, err := DecryptBase64(outer.Data, c.appkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("laosiji: decrypt data: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(plain, out); err != nil {
|
||||
return fmt.Errorf("laosiji: unmarshal data: %w (plain=%s)", err, truncate(plain, 200))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncate(b []byte, n int) string {
|
||||
if len(b) > n {
|
||||
return string(b[:n]) + "..."
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package laosiji
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCipher = errors.New("laosiji crypto: invalid ciphertext length")
|
||||
ErrInvalidPadding = errors.New("laosiji crypto: invalid pkcs7 padding")
|
||||
)
|
||||
|
||||
func AesKey(appkey string) []byte {
|
||||
key := make([]byte, 16)
|
||||
copy(key, appkey)
|
||||
return key
|
||||
}
|
||||
|
||||
func EncryptBase64(plain []byte, appkey string) (string, error) {
|
||||
block, err := aes.NewCipher(AesKey(appkey))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
padded := pkcs7Pad(plain, block.BlockSize())
|
||||
out := make([]byte, len(padded))
|
||||
ecbEncrypt(block, out, padded)
|
||||
return base64.StdEncoding.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
func DecryptBase64(b64 string, appkey string) ([]byte, error) {
|
||||
ct, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(AesKey(appkey))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bs := block.BlockSize()
|
||||
if len(ct) == 0 || len(ct)%bs != 0 {
|
||||
return nil, ErrInvalidCipher
|
||||
}
|
||||
out := make([]byte, len(ct))
|
||||
ecbDecrypt(block, out, ct)
|
||||
return pkcs7Unpad(out, bs)
|
||||
}
|
||||
|
||||
func ecbEncrypt(block cipher.Block, dst, src []byte) {
|
||||
bs := block.BlockSize()
|
||||
for len(src) > 0 {
|
||||
block.Encrypt(dst[:bs], src[:bs])
|
||||
src = src[bs:]
|
||||
dst = dst[bs:]
|
||||
}
|
||||
}
|
||||
|
||||
func ecbDecrypt(block cipher.Block, dst, src []byte) {
|
||||
bs := block.BlockSize()
|
||||
for len(src) > 0 {
|
||||
block.Decrypt(dst[:bs], src[:bs])
|
||||
src = src[bs:]
|
||||
dst = dst[bs:]
|
||||
}
|
||||
}
|
||||
|
||||
func pkcs7Pad(data []byte, blockSize int) []byte {
|
||||
pad := blockSize - len(data)%blockSize
|
||||
return append(data, bytes.Repeat([]byte{byte(pad)}, pad)...)
|
||||
}
|
||||
|
||||
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
|
||||
n := len(data)
|
||||
if n == 0 || n%blockSize != 0 {
|
||||
return nil, ErrInvalidPadding
|
||||
}
|
||||
pad := int(data[n-1])
|
||||
if pad == 0 || pad > blockSize {
|
||||
return nil, ErrInvalidPadding
|
||||
}
|
||||
for i := n - pad; i < n; i++ {
|
||||
if int(data[i]) != pad {
|
||||
return nil, ErrInvalidPadding
|
||||
}
|
||||
}
|
||||
return data[:n-pad], nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package laosiji
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const SystemDomainsPath = "/lsjapi/system/domains"
|
||||
|
||||
type SystemDomains struct {
|
||||
ImgFreeCdn string `json:"img_free_cdn"`
|
||||
ImgVipCdn string `json:"img_vip_cdn"`
|
||||
ImgCnCdn string `json:"img_cn_cdn"`
|
||||
ImgSourceCdn string `json:"img_source_cdn"`
|
||||
MovieFreeCdn string `json:"movie_free_cdn"`
|
||||
MovieVipCdn string `json:"movie_vip_cdn"`
|
||||
MovieCnCdn string `json:"movie_cn_cdn"`
|
||||
MovieSourceCdn string `json:"movie_source_cdn"`
|
||||
UploadURL string `json:"upload_url"`
|
||||
MediaDir string `json:"media_dir"`
|
||||
UploadKey string `json:"upload_key"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
type Domains struct {
|
||||
client *Client
|
||||
|
||||
mu sync.RWMutex
|
||||
cur *SystemDomains
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
func NewDomains(client *Client) *Domains {
|
||||
return &Domains{client: client}
|
||||
}
|
||||
|
||||
func (d *Domains) Refresh(ctx context.Context) (*SystemDomains, error) {
|
||||
var sd SystemDomains
|
||||
if err := d.client.Call(ctx, SystemDomainsPath, map[string]string{}, &sd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.cur = &sd
|
||||
d.fetchedAt = time.Now()
|
||||
d.mu.Unlock()
|
||||
return &sd, nil
|
||||
}
|
||||
|
||||
func (d *Domains) Get(ctx context.Context) (*SystemDomains, error) {
|
||||
d.mu.RLock()
|
||||
cur := d.cur
|
||||
d.mu.RUnlock()
|
||||
if cur != nil {
|
||||
return cur, nil
|
||||
}
|
||||
return d.Refresh(ctx)
|
||||
}
|
||||
|
||||
func (d *Domains) StartAutoRefresh(interval time.Duration) (stop func()) {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-t.C:
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
_, _ = d.Refresh(ctx)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}()
|
||||
return func() { close(done) }
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package laosiji
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
videoTranscodingVersion = "2.0"
|
||||
transcodeSuccessStatus = 5
|
||||
transcodeNoNeedStatus = 4
|
||||
transcodeFailedStatus = -1
|
||||
transcodeQueueLimit = 20
|
||||
)
|
||||
|
||||
var errTranscodeTaskNotFound = errors.New("laosiji transcode task not found")
|
||||
|
||||
type uploadAPIResp struct {
|
||||
Status string `json:"status"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
Time string `json:"time"`
|
||||
Error string `json:"error"`
|
||||
ErrorCode int `json:"errorCode"`
|
||||
}
|
||||
|
||||
type transcodeTask struct {
|
||||
ID string `json:"id"`
|
||||
FileID string `json:"file_id"`
|
||||
FileURL string `json:"file_url"`
|
||||
Status int `json:"status"`
|
||||
Duration int `json:"duration"`
|
||||
Height int `json:"height"`
|
||||
Width int `json:"width"`
|
||||
TranscodeError string `json:"transcode_error"`
|
||||
TranscodeFile string `json:"transcode_file"`
|
||||
PreviewVtt string `json:"preview_vtt"`
|
||||
AudioFile string `json:"audio_file"`
|
||||
SmartCover string `json:"smart_cover"`
|
||||
PreviewDir string `json:"preview_dir"`
|
||||
SrcMetaInfo *MetaInfo `json:"src_meta_info"`
|
||||
HevcMetaInfo *MetaInfo `json:"hevc_meta_info"`
|
||||
}
|
||||
|
||||
// MetaInfo 视频元信息(src_meta_info / hevc_meta_info 共用结构)。
|
||||
type MetaInfo struct {
|
||||
Codec string `json:"codec"`
|
||||
Profile string `json:"profile"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Fps float64 `json:"fps"`
|
||||
VideoBitrate int `json:"video_bitrate"`
|
||||
PixFmt string `json:"pix_fmt"`
|
||||
AudioCodec string `json:"audio_codec"`
|
||||
AudioBitrate int `json:"audio_bitrate"`
|
||||
AudioSampleRate int `json:"audio_sample_rate"`
|
||||
AudioChannels int `json:"audio_channels"`
|
||||
Bitrate int `json:"bitrate"`
|
||||
Size int64 `json:"size"`
|
||||
Duration float64 `json:"duration"`
|
||||
}
|
||||
|
||||
type transcodeQueueInfo struct {
|
||||
Waiting int `json:"waiting"`
|
||||
Done int `json:"done"`
|
||||
Error int `json:"error"`
|
||||
}
|
||||
|
||||
// TranscodeResult 转码任务状态快照。
|
||||
type TranscodeResult struct {
|
||||
Status int
|
||||
Done bool
|
||||
Failed bool
|
||||
HevcURL string
|
||||
PreviewVtt string
|
||||
AudioFile string
|
||||
SmartCover string
|
||||
HevcMeta *MetaInfo
|
||||
}
|
||||
|
||||
// ResolveTranscodeFileID 优先使用媒资 fileId,缺失时回退为 fileURL 的 md5。
|
||||
func ResolveTranscodeFileID(fileID, fileURL string) string {
|
||||
if id := strings.TrimSpace(fileID); id != "" {
|
||||
return id
|
||||
}
|
||||
return md5Hex(fileURL)
|
||||
}
|
||||
|
||||
func (c *Client) SubmitTranscoding(ctx context.Context, fileURL, fileID string, domains SystemDomains, videoId int64) (TranscodeResult, error) {
|
||||
fileURL = strings.TrimSpace(fileURL)
|
||||
if fileURL == "" {
|
||||
return TranscodeResult{}, fmt.Errorf("empty transcode source url")
|
||||
}
|
||||
fileID = ResolveTranscodeFileID(fileID, fileURL)
|
||||
|
||||
task, err := c.queryVideoTranscoding(ctx, domains, fileID)
|
||||
if err == nil {
|
||||
return taskToResult(task), nil
|
||||
}
|
||||
if !errors.Is(err, errTranscodeTaskNotFound) {
|
||||
return TranscodeResult{}, err
|
||||
}
|
||||
task, err = c.createVideoTranscoding(ctx, domains, fileID, fileURL, videoId)
|
||||
if err != nil {
|
||||
return TranscodeResult{}, err
|
||||
}
|
||||
return taskToResult(task), nil
|
||||
}
|
||||
|
||||
func (c *Client) QueryTranscoding(ctx context.Context, fileURL, fileID string, domains SystemDomains) (result TranscodeResult, found bool, err error) {
|
||||
fileURL = strings.TrimSpace(fileURL)
|
||||
if fileURL == "" {
|
||||
return TranscodeResult{}, false, fmt.Errorf("empty transcode source url")
|
||||
}
|
||||
fileID = ResolveTranscodeFileID(fileID, fileURL)
|
||||
|
||||
task, err := c.queryVideoTranscoding(ctx, domains, fileID)
|
||||
if err != nil {
|
||||
if errors.Is(err, errTranscodeTaskNotFound) {
|
||||
return TranscodeResult{}, false, nil
|
||||
}
|
||||
return TranscodeResult{}, false, err
|
||||
}
|
||||
return taskToResult(task), true, nil
|
||||
}
|
||||
|
||||
func (c *Client) createVideoTranscoding(ctx context.Context, domains SystemDomains, fileID, fileURL string, videoId int64) (transcodeTask, error) {
|
||||
if q, err := c.getVideoTranscodingQueue(ctx, domains); err == nil && q.Waiting >= transcodeQueueLimit {
|
||||
return transcodeTask{}, fmt.Errorf("h264toh265 queue full waiting:%d limit:%d", q.Waiting, transcodeQueueLimit)
|
||||
}
|
||||
endpoint := joinURLPath(domains.UploadURL, "upload/videoTranscoding")
|
||||
extData, _ := json.Marshal(map[string]interface{}{
|
||||
"project": "ttrgw",
|
||||
"url": fileURL,
|
||||
"type": "h265",
|
||||
"videoId": videoId,
|
||||
})
|
||||
return c.uploadAPIGet(ctx, endpoint, map[string]string{
|
||||
"v": videoTranscodingVersion,
|
||||
"key": domains.UploadKey,
|
||||
"file_id": fileID,
|
||||
"file_url": fileURL,
|
||||
"notice_url": "",
|
||||
"ext_data": string(extData),
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) queryVideoTranscoding(ctx context.Context, domains SystemDomains, fileID string) (transcodeTask, error) {
|
||||
endpoint := joinURLPath(domains.UploadURL, "upload/queryVideoTranscoding")
|
||||
return c.uploadAPIGet(ctx, endpoint, map[string]string{
|
||||
"v": videoTranscodingVersion,
|
||||
"key": domains.UploadKey,
|
||||
"file_id": fileID,
|
||||
})
|
||||
}
|
||||
|
||||
func taskToResult(task transcodeTask) TranscodeResult {
|
||||
r := TranscodeResult{Status: task.Status}
|
||||
switch {
|
||||
case task.Status == transcodeSuccessStatus || task.Status == transcodeNoNeedStatus:
|
||||
if task.Status == transcodeSuccessStatus {
|
||||
if f := strings.TrimSpace(task.TranscodeFile); f != "" {
|
||||
r.Done = true
|
||||
r.HevcURL = joinURLPath("/laosiji/m3m", f)
|
||||
}
|
||||
} else {
|
||||
r.Done = true
|
||||
}
|
||||
if r.Done {
|
||||
r.PreviewVtt = laosijiPreviewURL(task.PreviewDir, task.PreviewVtt)
|
||||
r.AudioFile = laosijiURL(task.AudioFile)
|
||||
r.SmartCover = laosijiPreviewURL(task.PreviewDir, task.SmartCover)
|
||||
r.HevcMeta = task.HevcMetaInfo
|
||||
}
|
||||
case task.Status <= transcodeFailedStatus:
|
||||
r.Failed = true
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func laosijiURL(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
return joinURLPath("/laosiji", name)
|
||||
}
|
||||
|
||||
func laosijiPreviewURL(previewDir, name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
return joinURLPath(joinURLPath("/laosiji", strings.TrimSpace(previewDir)), name)
|
||||
}
|
||||
|
||||
func (c *Client) getVideoTranscodingQueue(ctx context.Context, domains SystemDomains) (transcodeQueueInfo, error) {
|
||||
endpoint := joinURLPath(domains.UploadURL, "upload/getVideoTranscodingQueueInfo")
|
||||
data, err := c.uploadAPICall(ctx, endpoint, map[string]string{
|
||||
"v": videoTranscodingVersion,
|
||||
"key": domains.UploadKey,
|
||||
})
|
||||
if err != nil {
|
||||
return transcodeQueueInfo{}, err
|
||||
}
|
||||
var q transcodeQueueInfo
|
||||
if len(data) > 0 {
|
||||
if err = json.Unmarshal(data, &q); err != nil {
|
||||
return transcodeQueueInfo{}, err
|
||||
}
|
||||
}
|
||||
return q, nil
|
||||
}
|
||||
|
||||
func (c *Client) uploadAPIGet(ctx context.Context, endpoint string, params map[string]string) (transcodeTask, error) {
|
||||
data, err := c.uploadAPICall(ctx, endpoint, params)
|
||||
if err != nil {
|
||||
return transcodeTask{}, err
|
||||
}
|
||||
var task transcodeTask
|
||||
if len(data) > 0 {
|
||||
if err = json.Unmarshal(data, &task); err != nil {
|
||||
return transcodeTask{}, err
|
||||
}
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (c *Client) uploadAPICall(ctx context.Context, endpoint string, params map[string]string) (json.RawMessage, error) {
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := parsed.Query()
|
||||
for k, v := range params {
|
||||
query.Set(k, v)
|
||||
}
|
||||
parsed.RawQuery = query.Encode()
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.hc.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("upload api http status:%d body:%s", resp.StatusCode, truncate(body, 300))
|
||||
}
|
||||
var apiResp uploadAPIResp
|
||||
if err = json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if apiResp.Status != "y" {
|
||||
if apiResp.ErrorCode == 2000 {
|
||||
return nil, errTranscodeTaskNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("upload api errorCode:%d error:%s", apiResp.ErrorCode, apiResp.Error)
|
||||
}
|
||||
return apiResp.Data, nil
|
||||
}
|
||||
|
||||
func md5Hex(value string) string {
|
||||
sum := md5.Sum([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func joinURLPath(host, uri string) string {
|
||||
host = strings.TrimRight(strings.TrimSpace(host), "/")
|
||||
uri = strings.TrimLeft(strings.TrimSpace(uri), "/")
|
||||
if host == "" {
|
||||
return uri
|
||||
}
|
||||
if uri == "" {
|
||||
return host
|
||||
}
|
||||
return host + "/" + uri
|
||||
}
|
||||
Reference in New Issue
Block a user