初始提交:短剧库源码(含转码优化 ultrafast)

This commit is contained in:
yvan
2026-09-17 19:09:08 +08:00
commit 07dc0f1059
247 changed files with 43242 additions and 0 deletions
+348
View File
@@ -0,0 +1,348 @@
package app
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type Tab struct {
ID string
Name string
}
type legacyTabList []Tab
func (tabs *legacyTabList) UnmarshalJSON(raw []byte) error {
raw = bytes.TrimSpace(raw)
if len(raw) > 0 && raw[0] == '[' {
return json.Unmarshal(raw, (*[]Tab)(tabs))
}
var wrapped struct {
List []Tab `json:"list"`
}
if err := json.Unmarshal(raw, &wrapped); err != nil {
return err
}
*tabs = wrapped.List
return nil
}
type apiEnvelope struct {
Code json.RawMessage `json:"code"`
Msg string `json:"msg"`
Hash json.RawMessage `json:"hash"`
Data json.RawMessage `json:"data"`
}
func apiHashEnabled(raw json.RawMessage) bool {
if len(raw) == 0 || string(raw) == "null" {
return false
}
var b bool
if err := json.Unmarshal(raw, &b); err == nil {
return b
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
s = strings.TrimSpace(strings.ToLower(s))
return s != "" && s != "false" && s != "0" && s != "null"
}
return true
}
type listResponse struct {
List []Drama `json:"list"`
}
type detailResponse struct {
Title string `json:"title"`
Name string `json:"name"`
Chapters []Chapter `json:"chapters"`
}
func pkcs7Pad(src []byte, blockSize int) []byte {
pad := blockSize - len(src)%blockSize
return append(src, bytes.Repeat([]byte{byte(pad)}, pad)...)
}
func pkcs7Unpad(src []byte, blockSize int) ([]byte, error) {
if len(src) == 0 || len(src)%blockSize != 0 {
return nil, errors.New("invalid pkcs7 length")
}
pad := int(src[len(src)-1])
if pad == 0 || pad > blockSize || pad > len(src) {
return nil, errors.New("invalid pkcs7 padding")
}
for _, v := range src[len(src)-pad:] {
if int(v) != pad {
return nil, errors.New("invalid pkcs7 padding bytes")
}
}
return src[:len(src)-pad], nil
}
func encryptParam(obj any, key, iv []byte) (string, error) {
plain, err := json.Marshal(obj)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
padded := pkcs7Pad(plain, block.BlockSize())
out := make([]byte, len(padded))
cipher.NewCBCEncrypter(block, iv).CryptBlocks(out, padded)
return base64.StdEncoding.EncodeToString(out), nil
}
func sha256Bytes(b []byte) []byte {
h := sha256.Sum256(b)
return h[:]
}
func decryptResponse(dataStr string, interfaceKey []byte) ([]byte, error) {
o, err := base64.StdEncoding.DecodeString(strings.TrimSpace(dataStr))
if err != nil {
return nil, err
}
if len(o) < 12 {
return nil, errors.New("encrypted payload too short")
}
i := o[:12]
r := append(append([]byte{}, interfaceKey...), i...)
l := len(r) >> 1
s := sha256Bytes(r)[8:24]
c := append(append([]byte{}, s...), r[:l]...)
a := append(append([]byte{}, r[l:]...), s...)
d := sha256Bytes(c)
f := sha256Bytes(a)
m := append(append(append([]byte{}, d[:8]...), f[8:24]...), d[24:]...)
p := append(append(append([]byte{}, f[:4]...), d[12:20]...), f[28:]...)
g := o[12:]
block, err := aes.NewCipher(m)
if err != nil {
return nil, err
}
if len(g)%block.BlockSize() != 0 {
return nil, errors.New("ciphertext is not block aligned")
}
plain := make([]byte, len(g))
cipher.NewCBCDecrypter(block, p).CryptBlocks(plain, g)
return pkcs7Unpad(plain, block.BlockSize())
}
func (d *Downloader) fetchAPI(ctx context.Context, apiPath string, params any, out any) error {
for attempt := 0; attempt < 2; attempt++ {
access, err := d.legacyCredentials(ctx)
if err != nil {
return err
}
payload, err := d.legacyRequest(ctx, http.MethodGet, apiPath, params, access)
var apiErr *legacyAPIError
if attempt == 0 && access.Anonymous && errors.As(err, &apiErr) && apiErr.code == "5005" {
d.invalidateLegacyToken(access)
continue
}
if err != nil {
return err
}
return json.Unmarshal(payload, out)
}
return errors.New("黄果访客登录已失效,请稍后重试")
}
type legacyAPIError struct{ code, message string }
func (err *legacyAPIError) Error() string {
return fmt.Sprintf("黄果 API 错误 %s: %s", err.code, err.message)
}
func (d *Downloader) legacyRequest(ctx context.Context, method, apiPath string, params any, access legacyAccess) ([]byte, error) {
var lastErr error
attempts := d.cfg.Retries
if attempts < 1 {
attempts = 1
}
for attempt := 1; attempt <= attempts; attempt++ {
if attempt > 1 {
select {
case <-time.After(time.Duration(attempt) * time.Second):
case <-ctx.Done():
return nil, ctx.Err()
}
}
fullPath := apiPath
var requestBody io.Reader
if params != nil {
enc, err := encryptParam(params, []byte(access.ParamKey), []byte(access.ParamIV))
if err != nil {
return nil, err
}
if method == http.MethodGet {
sep := "?"
if strings.Contains(fullPath, "?") {
sep = "&"
}
fullPath += sep + "data=" + url.QueryEscape(enc)
} else {
body, _ := json.Marshal(map[string]string{"data": enc})
requestBody = bytes.NewReader(body)
}
}
base, err := d.apiEndpoint(ctx)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, method, base+fullPath, requestBody)
if err != nil {
return nil, err
}
if access.Token != "" {
req.Header.Set("Authorization", access.Token)
}
if requestBody != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
req.Header.Set("temp", "test")
req.Header.Set("X-User-Agent", access.userAgent())
req.Header.Set("Origin", legacyFrontendURL)
req.Header.Set("Referer", legacyFrontendURL+"/")
req.Header.Set("User-Agent", userAgent)
resp, err := d.doCatalogRequest(req)
if err != nil {
lastErr = publicError(err)
var backoff *requestBackoff
if errors.As(err, &backoff) || ctx.Err() != nil {
return nil, lastErr
}
d.resetAPIEndpoint(base)
continue
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, providerMaxBodyBytes+1))
_ = resp.Body.Close()
if readErr != nil {
lastErr = readErr
continue
}
if len(body) > providerMaxBodyBytes {
return nil, errors.New("API 响应过大")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
lastErr = d.catalogResponseError(req, resp, body)
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
return nil, lastErr
}
continue
}
var env apiEnvelope
if err := json.Unmarshal(body, &env); err != nil {
lastErr = err
continue
}
if code := strings.Trim(string(env.Code), `"`); code != "" && code != "null" && code != "200" && code != "0" {
message := firstNonEmpty(env.Msg, "请检查访问配置或刷新登录凭证")
if access.Token != "" {
message = strings.ReplaceAll(message, access.Token, "[已隐藏]")
}
return nil, &legacyAPIError{code: truncate(code, 20), message: truncate(message, 160)}
}
payload := env.Data
if apiHashEnabled(env.Hash) && len(env.Data) > 0 {
var encrypted string
if err := json.Unmarshal(env.Data, &encrypted); err != nil {
lastErr = err
continue
}
plain, err := decryptResponse(encrypted, []byte(access.InterfaceKey))
if err != nil {
lastErr = err
continue
}
payload = plain
}
if len(payload) == 0 || string(payload) == "null" {
return nil, errors.New("黄果 API 未返回数据,请检查登录凭证或接口是否变更")
}
return payload, nil
}
return nil, lastErr
}
func (d *Downloader) fetchCloudFrontDramas(ctx context.Context) ([]Drama, error) {
var activeTabs legacyTabList
if err := d.fetchAPI(ctx, "/api/app/playlet-tab/all", nil, &activeTabs); err != nil {
return nil, err
}
if len(activeTabs) == 0 {
return nil, errors.New("黄果 API 未返回可用分类")
}
sortTypes := []string{"0", "1", "6"}
seen := map[string]bool{}
var unique []Drama
var errs []string
for _, sortType := range sortTypes {
for _, tab := range activeTabs {
if tab.ID == "" {
continue
}
for page := 1; page <= d.cfg.MaxPagesPerSort; page++ {
if err := ctx.Err(); err != nil {
return unique, err
}
var lr listResponse
err := d.fetchAPI(ctx, "/api/app/playlet/home/tab/"+tab.ID, map[string]string{
"pageNumber": strconv.Itoa(page),
"pageSize": strconv.Itoa(d.cfg.PageSize),
"tabSortType": sortType,
}, &lr)
if err != nil {
msg := fmt.Sprintf("频道[%s] 排序[%s] 第%d页失败: %v", tab.Name, sortType, page, err)
fmt.Printf(" %s\n", msg)
errs = append(errs, msg)
break
}
if len(lr.List) == 0 {
break
}
added := 0
var batch []Drama
for _, item := range lr.List {
if item.ID == "" || seen[item.ID] {
continue
}
item.ChannelName = tab.Name
item.Source = "cloudfront"
item.CategoryName = firstNonEmpty(item.CategoryName, item.CategoryNameSnake, item.Category, tab.Name)
seen[item.ID] = true
unique = append(unique, item)
batch = append(batch, item)
added++
}
reportLibraryProgress(ctx, "cloudfront", batch, nil, false)
if added == 0 || len(lr.List) < d.cfg.PageSize {
break
}
}
}
}
if len(errs) > 0 {
return unique, errors.New(strings.Join(errs, "\n"))
}
return unique, nil
}