hdapi / main.go
wd21's picture
Upload 4 files
a3a90ea verified
package main
import (
"bytes"
"context"
_ "embed"
"encoding/json"
"io"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
//go:embed index.html
var indexHTML string
const (
defaultListenAddr = ":8890"
defaultBaseURL = "https://hdhive.com"
defaultTMDBBaseURL = "https://api.tmdb.org"
defaultTimeout = 20 * time.Second
openAPIPath = "/api/open"
)
type config struct {
ListenAddr string
BaseURL string
DefaultAPIKey string
TMDBBaseURL string
DefaultTMDBKey string
Timeout time.Duration
}
type upstreamClient struct {
baseURL string
defaultAPIKey string
tmdbBaseURL string
defaultTMDBKey string
httpClient *http.Client
}
type unlockRequest struct {
Slug string `json:"slug"`
AllowPoints *bool `json:"allow_points,omitempty"`
}
type shareDetailResponse struct {
Success bool `json:"success"`
Code string `json:"code"`
Message string `json:"message"`
Data shareDetailData `json:"data"`
}
type shareDetailData struct {
Slug string `json:"slug"`
ActualUnlockPoints int `json:"actual_unlock_points"`
IsUnlocked bool `json:"is_unlocked"`
IsFreeForUser bool `json:"is_free_for_user"`
UnlockMessage string `json:"unlock_message"`
}
type localError struct {
Success bool `json:"success"`
Code string `json:"code"`
Message string `json:"message"`
Description string `json:"description,omitempty"`
Data interface{} `json:"data,omitempty"`
}
func main() {
cfg := loadConfig()
client := newUpstreamClient(cfg)
router := gin.Default()
registerRoutes(router, client)
log.Printf("HDHive test client listening on %s (upstream: %s%s)", cfg.ListenAddr, cfg.BaseURL, openAPIPath)
if err := router.Run(cfg.ListenAddr); err != nil {
log.Fatalf("failed to start HDHive test client: %v", err)
}
}
func loadConfig() config {
timeout := defaultTimeout
if raw := strings.TrimSpace(os.Getenv("HDHIVE_TIMEOUT_SECONDS")); raw != "" {
if seconds, err := strconv.Atoi(raw); err == nil && seconds > 0 {
timeout = time.Duration(seconds) * time.Second
}
}
baseURL := strings.TrimSpace(os.Getenv("HDHIVE_BASE_URL"))
if baseURL == "" {
baseURL = defaultBaseURL
}
baseURL = strings.TrimSuffix(baseURL, "/")
baseURL = strings.TrimSuffix(baseURL, openAPIPath)
tmdbBaseURL := strings.TrimSpace(os.Getenv("TMDB_BASE_URL"))
if tmdbBaseURL == "" {
tmdbBaseURL = defaultTMDBBaseURL
}
tmdbBaseURL = normalizeTMDBBaseURL(tmdbBaseURL)
listenAddr := strings.TrimSpace(os.Getenv("HDHIVE_LISTEN_ADDR"))
if listenAddr == "" {
listenAddr = defaultListenAddr
}
return config{
ListenAddr: listenAddr,
BaseURL: baseURL,
DefaultAPIKey: strings.TrimSpace(os.Getenv("HDHIVE_API_KEY")),
TMDBBaseURL: tmdbBaseURL,
DefaultTMDBKey: strings.TrimSpace(os.Getenv("TMDB_API_KEY")),
Timeout: timeout,
}
}
func newUpstreamClient(cfg config) *upstreamClient {
return &upstreamClient{
baseURL: cfg.BaseURL,
defaultAPIKey: cfg.DefaultAPIKey,
tmdbBaseURL: cfg.TMDBBaseURL,
defaultTMDBKey: cfg.DefaultTMDBKey,
httpClient: &http.Client{
Timeout: cfg.Timeout,
},
}
}
func registerRoutes(router *gin.Engine, client *upstreamClient) {
router.Use(corsMiddleware())
router.GET("/", func(c *gin.Context) {
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(indexHTML))
})
router.GET("/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "ok",
"upstream": client.baseURL + openAPIPath,
})
})
router.GET(openAPIPath+"/ping", client.handleProxy(http.MethodGet, "/ping"))
router.GET(openAPIPath+"/quota", client.handleProxy(http.MethodGet, "/quota"))
router.GET(openAPIPath+"/usage", client.handleProxy(http.MethodGet, "/usage"))
router.GET(openAPIPath+"/usage/today", client.handleProxy(http.MethodGet, "/usage/today"))
router.GET(openAPIPath+"/resources/:type/:tmdb_id", client.handleProxy(http.MethodGet, "/resources/:type/:tmdb_id"))
router.POST(openAPIPath+"/resources/unlock", client.handleUnlock())
router.POST(openAPIPath+"/check/resource", client.handleProxy(http.MethodPost, "/check/resource"))
router.GET(openAPIPath+"/shares", client.handleProxy(http.MethodGet, "/shares"))
router.POST(openAPIPath+"/shares", client.handleProxy(http.MethodPost, "/shares"))
router.GET(openAPIPath+"/shares/:slug", client.handleProxy(http.MethodGet, "/shares/:slug"))
router.PATCH(openAPIPath+"/shares/:slug", client.handleProxy(http.MethodPatch, "/shares/:slug"))
router.DELETE(openAPIPath+"/shares/:slug", client.handleProxy(http.MethodDelete, "/shares/:slug"))
router.GET("/api/tmdb/configuration", client.handleTMDBProxy(http.MethodGet, "/configuration"))
router.GET("/api/tmdb/configuration/primary_translations", client.handleTMDBProxy(http.MethodGet, "/configuration/primary_translations"))
router.GET("/api/tmdb/search/:media_type", client.handleTMDBProxy(http.MethodGet, "/search/:media_type"))
router.GET("/api/tmdb/:media_type/:media_id", client.handleTMDBProxy(http.MethodGet, "/:media_type/:media_id"))
}
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
headers := c.Writer.Header()
headers.Set("Access-Control-Allow-Origin", "*")
headers.Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key, X-TMDB-API-Key")
headers.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
headers.Set("Access-Control-Expose-Headers", "Content-Type, X-RateLimit-Reset, X-Endpoint-Limit, X-Endpoint-Remaining, Retry-After")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
func (u *upstreamClient) handleProxy(method string, pathTemplate string) gin.HandlerFunc {
return func(c *gin.Context) {
body, status, headers, err := u.forwardRequest(
c.Request.Context(),
method,
expandPath(pathTemplate, c),
copyQuery(c.Request.URL.Query()),
readRequestBody(c.Request),
u.resolveAPIKey(c.GetHeader("X-API-Key")),
c.GetHeader("Content-Type"),
)
if err != nil {
respondLocalError(c, http.StatusBadGateway, "UPSTREAM_REQUEST_FAILED", err.Error(), "")
return
}
copyResponseHeaders(c.Writer.Header(), headers)
c.Data(status, contentTypeFromHeaders(headers), body)
}
}
func (u *upstreamClient) handleUnlock() gin.HandlerFunc {
return func(c *gin.Context) {
rawBody := readRequestBody(c.Request)
if len(rawBody) == 0 {
respondLocalError(c, http.StatusBadRequest, "400", "request body is required", "请求体不能为空")
return
}
var req unlockRequest
if err := json.Unmarshal(rawBody, &req); err != nil {
respondLocalError(c, http.StatusBadRequest, "400", "invalid json body", "请求体 JSON 格式无效")
return
}
req.Slug = strings.TrimSpace(req.Slug)
if req.Slug == "" {
respondLocalError(c, http.StatusBadRequest, "400", "slug is required", "缺少 slug")
return
}
apiKey := u.resolveAPIKey(c.GetHeader("X-API-Key"))
if apiKey == "" {
respondLocalError(c, http.StatusUnauthorized, "MISSING_API_KEY", "API Key is required", "请通过请求头或环境变量提供 X-API-Key")
return
}
allowPoints := false
if req.AllowPoints != nil {
allowPoints = *req.AllowPoints
}
if !allowPoints {
detail, errResp := u.lookupShareDetailForUnlock(c.Request.Context(), apiKey, req.Slug)
if errResp != nil {
copyResponseHeaders(c.Writer.Header(), errResp.Headers)
c.Data(errResp.StatusCode, contentTypeFromHeaders(errResp.Headers), errResp.Body)
return
}
if !isSafeToUnlock(detail) {
respondLocalError(
c,
http.StatusConflict,
"POINTS_REQUIRED",
"resource requires points; set allow_points=true to unlock",
"该资源当前需要扣积分。默认不允许扣积分解锁,请显式传 allow_points=true 后再执行。",
gin.H{
"slug": detail.Slug,
"actual_unlock_points": detail.ActualUnlockPoints,
"is_unlocked": detail.IsUnlocked,
"is_free_for_user": detail.IsFreeForUser,
"unlock_message": detail.UnlockMessage,
},
)
return
}
}
upstreamBody, _ := json.Marshal(gin.H{"slug": req.Slug})
body, status, headers, err := u.forwardRequest(
c.Request.Context(),
http.MethodPost,
"/resources/unlock",
nil,
upstreamBody,
apiKey,
"application/json",
)
if err != nil {
respondLocalError(c, http.StatusBadGateway, "UPSTREAM_REQUEST_FAILED", err.Error(), "")
return
}
copyResponseHeaders(c.Writer.Header(), headers)
c.Data(status, contentTypeFromHeaders(headers), body)
}
}
func (u *upstreamClient) handleTMDBProxy(method string, pathTemplate string) gin.HandlerFunc {
return func(c *gin.Context) {
if mediaType := strings.TrimSpace(c.Param("media_type")); mediaType != "" {
switch mediaType {
case "movie", "tv", "multi":
default:
respondLocalError(c, http.StatusBadRequest, "400", "invalid media_type", "media_type 只能是 movie、tv 或 multi")
return
}
}
tmdbKey := u.resolveTMDBAPIKey(c.GetHeader("X-TMDB-API-Key"))
if tmdbKey == "" {
respondLocalError(c, http.StatusUnauthorized, "MISSING_TMDB_API_KEY", "TMDB API Key is required", "请通过请求头 X-TMDB-API-Key 或环境变量 TMDB_API_KEY 提供 TMDB API Key")
return
}
body, status, headers, err := u.forwardTMDBRequest(
c.Request.Context(),
method,
expandPath(pathTemplate, c),
copyQuery(c.Request.URL.Query()),
readRequestBody(c.Request),
tmdbKey,
c.GetHeader("Content-Type"),
)
if err != nil {
respondLocalError(c, http.StatusBadGateway, "TMDB_REQUEST_FAILED", err.Error(), "")
return
}
copyResponseHeaders(c.Writer.Header(), headers)
c.Data(status, contentTypeFromHeaders(headers), body)
}
}
type proxyResponse struct {
StatusCode int
Headers http.Header
Body []byte
}
func (u *upstreamClient) lookupShareDetailForUnlock(ctx context.Context, apiKey, slug string) (*shareDetailData, *proxyResponse) {
normalizedSlug := normalizeSlug(slug)
body, status, headers, err := u.forwardRequest(
ctx,
http.MethodGet,
"/shares/"+url.PathEscape(normalizedSlug),
nil,
nil,
apiKey,
"",
)
if err != nil {
return nil, &proxyResponse{
StatusCode: http.StatusBadGateway,
Headers: http.Header{"Content-Type": []string{"application/json"}},
Body: mustMarshal(localError{
Success: false,
Code: "UPSTREAM_REQUEST_FAILED",
Message: err.Error(),
}),
}
}
if status < http.StatusOK || status >= http.StatusMultipleChoices {
return nil, &proxyResponse{StatusCode: status, Headers: headers, Body: body}
}
var parsed shareDetailResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, &proxyResponse{
StatusCode: http.StatusBadGateway,
Headers: http.Header{"Content-Type": []string{"application/json"}},
Body: mustMarshal(localError{
Success: false,
Code: "UPSTREAM_RESPONSE_INVALID",
Message: "failed to parse share detail response",
Description: "上游 shares detail 接口返回的数据结构无法解析",
}),
}
}
parsed.Data.Slug = normalizedSlug
return &parsed.Data, nil
}
func (u *upstreamClient) forwardRequest(
ctx context.Context,
method string,
path string,
query url.Values,
body []byte,
apiKey string,
contentType string,
) ([]byte, int, http.Header, error) {
targetURL := u.baseURL + openAPIPath + path
if len(query) > 0 {
targetURL += "?" + query.Encode()
}
var bodyReader io.Reader
if len(body) > 0 {
bodyReader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, targetURL, bodyReader)
if err != nil {
return nil, 0, nil, err
}
req.Header.Set("Accept", "application/json")
if apiKey != "" {
req.Header.Set("X-API-Key", apiKey)
}
if len(body) > 0 {
if contentType == "" {
contentType = "application/json"
}
req.Header.Set("Content-Type", contentType)
}
resp, err := u.httpClient.Do(req)
if err != nil {
return nil, 0, nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, 0, nil, err
}
return respBody, resp.StatusCode, resp.Header.Clone(), nil
}
func (u *upstreamClient) resolveAPIKey(requestKey string) string {
if strings.TrimSpace(requestKey) != "" {
return strings.TrimSpace(requestKey)
}
return u.defaultAPIKey
}
func (u *upstreamClient) resolveTMDBAPIKey(requestKey string) string {
if strings.TrimSpace(requestKey) != "" {
return strings.TrimSpace(requestKey)
}
return u.defaultTMDBKey
}
func (u *upstreamClient) forwardTMDBRequest(
ctx context.Context,
method string,
path string,
query url.Values,
body []byte,
apiKey string,
contentType string,
) ([]byte, int, http.Header, error) {
targetQuery := copyQuery(query)
if targetQuery == nil {
targetQuery = make(url.Values)
}
targetQuery.Set("api_key", apiKey)
targetURL := u.tmdbBaseURL + path
if len(targetQuery) > 0 {
targetURL += "?" + targetQuery.Encode()
}
var bodyReader io.Reader
if len(body) > 0 {
bodyReader = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, targetURL, bodyReader)
if err != nil {
return nil, 0, nil, err
}
req.Header.Set("Accept", "application/json")
if len(body) > 0 {
if contentType == "" {
contentType = "application/json"
}
req.Header.Set("Content-Type", contentType)
}
resp, err := u.httpClient.Do(req)
if err != nil {
return nil, 0, nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, 0, nil, err
}
return respBody, resp.StatusCode, resp.Header.Clone(), nil
}
func expandPath(template string, c *gin.Context) string {
result := template
for _, key := range []string{"type", "tmdb_id", "slug", "media_type", "media_id"} {
result = strings.ReplaceAll(result, ":"+key, url.PathEscape(c.Param(key)))
}
return result
}
func copyQuery(values url.Values) url.Values {
if values == nil {
return nil
}
cloned := make(url.Values, len(values))
for key, items := range values {
cloned[key] = append([]string(nil), items...)
}
return cloned
}
func readRequestBody(req *http.Request) []byte {
if req == nil || req.Body == nil {
return nil
}
body, err := io.ReadAll(req.Body)
if err != nil {
return nil
}
req.Body = io.NopCloser(bytes.NewReader(body))
return body
}
func normalizeSlug(slug string) string {
replacer := strings.NewReplacer("-", "", " ", "")
return strings.ToLower(strings.TrimSpace(replacer.Replace(slug)))
}
func isSafeToUnlock(detail *shareDetailData) bool {
if detail == nil {
return false
}
if detail.IsUnlocked || detail.IsFreeForUser {
return true
}
return detail.ActualUnlockPoints <= 0
}
func normalizeTMDBBaseURL(value string) string {
normalized := strings.TrimSpace(value)
if normalized == "" {
normalized = defaultTMDBBaseURL
}
normalized = strings.TrimSuffix(normalized, "/")
if strings.HasSuffix(normalized, "/3") {
return normalized
}
return normalized + "/3"
}
func copyResponseHeaders(dst, src http.Header) {
if dst == nil || src == nil {
return
}
for _, key := range []string{
"Content-Type",
"X-RateLimit-Reset",
"X-Endpoint-Limit",
"X-Endpoint-Remaining",
"Retry-After",
} {
values := src.Values(key)
if len(values) == 0 {
continue
}
dst.Del(key)
for _, value := range values {
dst.Add(key, value)
}
}
}
func contentTypeFromHeaders(headers http.Header) string {
if headers == nil {
return "application/json"
}
if value := headers.Get("Content-Type"); value != "" {
return value
}
return "application/json"
}
func respondLocalError(c *gin.Context, status int, code, message, description string, data ...interface{}) {
resp := localError{
Success: false,
Code: code,
Message: message,
Description: description,
}
if len(data) > 0 {
resp.Data = data[0]
}
c.JSON(status, resp)
}
func mustMarshal(v interface{}) []byte {
data, err := json.Marshal(v)
if err != nil {
panic(err)
}
return data
}
func init() {
gin.SetMode(resolveGinMode())
}
func resolveGinMode() string {
mode := strings.TrimSpace(os.Getenv("GIN_MODE"))
switch mode {
case gin.DebugMode, gin.ReleaseMode, gin.TestMode:
return mode
case "":
return gin.ReleaseMode
default:
return gin.ReleaseMode
}
}