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 }