wd21 commited on
Commit
a3a90ea
·
verified ·
1 Parent(s): 1f0a202

Upload 4 files

Browse files
Files changed (5) hide show
  1. .gitattributes +2 -0
  2. hdapi-linux-amd64 +3 -0
  3. hdapi-linux-arm64 +3 -0
  4. index.html +0 -0
  5. main.go +613 -0
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ hdapi-linux-amd64 filter=lfs diff=lfs merge=lfs -text
37
+ hdapi-linux-arm64 filter=lfs diff=lfs merge=lfs -text
hdapi-linux-amd64 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0ade09f5c376a28c5facad7f3b1fdc845d137f675ef8aff81138011cce5ca622
3
+ size 8925368
hdapi-linux-arm64 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8d3051c401127c48003f7db87157f2caa622ed292af3e1ff351948fc075c6eeb
3
+ size 8454328
index.html ADDED
The diff for this file is too large to render. See raw diff
 
main.go ADDED
@@ -0,0 +1,613 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ _ "embed"
7
+ "encoding/json"
8
+ "io"
9
+ "log"
10
+ "net/http"
11
+ "net/url"
12
+ "os"
13
+ "strconv"
14
+ "strings"
15
+ "time"
16
+
17
+ "github.com/gin-gonic/gin"
18
+ )
19
+
20
+ //go:embed index.html
21
+ var indexHTML string
22
+
23
+ const (
24
+ defaultListenAddr = ":8890"
25
+ defaultBaseURL = "https://hdhive.com"
26
+ defaultTMDBBaseURL = "https://api.tmdb.org"
27
+ defaultTimeout = 20 * time.Second
28
+ openAPIPath = "/api/open"
29
+ )
30
+
31
+ type config struct {
32
+ ListenAddr string
33
+ BaseURL string
34
+ DefaultAPIKey string
35
+ TMDBBaseURL string
36
+ DefaultTMDBKey string
37
+ Timeout time.Duration
38
+ }
39
+
40
+ type upstreamClient struct {
41
+ baseURL string
42
+ defaultAPIKey string
43
+ tmdbBaseURL string
44
+ defaultTMDBKey string
45
+ httpClient *http.Client
46
+ }
47
+
48
+ type unlockRequest struct {
49
+ Slug string `json:"slug"`
50
+ AllowPoints *bool `json:"allow_points,omitempty"`
51
+ }
52
+
53
+ type shareDetailResponse struct {
54
+ Success bool `json:"success"`
55
+ Code string `json:"code"`
56
+ Message string `json:"message"`
57
+ Data shareDetailData `json:"data"`
58
+ }
59
+
60
+ type shareDetailData struct {
61
+ Slug string `json:"slug"`
62
+ ActualUnlockPoints int `json:"actual_unlock_points"`
63
+ IsUnlocked bool `json:"is_unlocked"`
64
+ IsFreeForUser bool `json:"is_free_for_user"`
65
+ UnlockMessage string `json:"unlock_message"`
66
+ }
67
+
68
+ type localError struct {
69
+ Success bool `json:"success"`
70
+ Code string `json:"code"`
71
+ Message string `json:"message"`
72
+ Description string `json:"description,omitempty"`
73
+ Data interface{} `json:"data,omitempty"`
74
+ }
75
+
76
+ func main() {
77
+ cfg := loadConfig()
78
+ client := newUpstreamClient(cfg)
79
+
80
+ router := gin.Default()
81
+ registerRoutes(router, client)
82
+
83
+ log.Printf("HDHive test client listening on %s (upstream: %s%s)", cfg.ListenAddr, cfg.BaseURL, openAPIPath)
84
+ if err := router.Run(cfg.ListenAddr); err != nil {
85
+ log.Fatalf("failed to start HDHive test client: %v", err)
86
+ }
87
+ }
88
+
89
+ func loadConfig() config {
90
+ timeout := defaultTimeout
91
+ if raw := strings.TrimSpace(os.Getenv("HDHIVE_TIMEOUT_SECONDS")); raw != "" {
92
+ if seconds, err := strconv.Atoi(raw); err == nil && seconds > 0 {
93
+ timeout = time.Duration(seconds) * time.Second
94
+ }
95
+ }
96
+
97
+ baseURL := strings.TrimSpace(os.Getenv("HDHIVE_BASE_URL"))
98
+ if baseURL == "" {
99
+ baseURL = defaultBaseURL
100
+ }
101
+ baseURL = strings.TrimSuffix(baseURL, "/")
102
+ baseURL = strings.TrimSuffix(baseURL, openAPIPath)
103
+
104
+ tmdbBaseURL := strings.TrimSpace(os.Getenv("TMDB_BASE_URL"))
105
+ if tmdbBaseURL == "" {
106
+ tmdbBaseURL = defaultTMDBBaseURL
107
+ }
108
+ tmdbBaseURL = normalizeTMDBBaseURL(tmdbBaseURL)
109
+
110
+ listenAddr := strings.TrimSpace(os.Getenv("HDHIVE_LISTEN_ADDR"))
111
+ if listenAddr == "" {
112
+ listenAddr = defaultListenAddr
113
+ }
114
+
115
+ return config{
116
+ ListenAddr: listenAddr,
117
+ BaseURL: baseURL,
118
+ DefaultAPIKey: strings.TrimSpace(os.Getenv("HDHIVE_API_KEY")),
119
+ TMDBBaseURL: tmdbBaseURL,
120
+ DefaultTMDBKey: strings.TrimSpace(os.Getenv("TMDB_API_KEY")),
121
+ Timeout: timeout,
122
+ }
123
+ }
124
+
125
+ func newUpstreamClient(cfg config) *upstreamClient {
126
+ return &upstreamClient{
127
+ baseURL: cfg.BaseURL,
128
+ defaultAPIKey: cfg.DefaultAPIKey,
129
+ tmdbBaseURL: cfg.TMDBBaseURL,
130
+ defaultTMDBKey: cfg.DefaultTMDBKey,
131
+ httpClient: &http.Client{
132
+ Timeout: cfg.Timeout,
133
+ },
134
+ }
135
+ }
136
+
137
+ func registerRoutes(router *gin.Engine, client *upstreamClient) {
138
+ router.Use(corsMiddleware())
139
+
140
+ router.GET("/", func(c *gin.Context) {
141
+ c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(indexHTML))
142
+ })
143
+
144
+ router.GET("/healthz", func(c *gin.Context) {
145
+ c.JSON(http.StatusOK, gin.H{
146
+ "success": true,
147
+ "message": "ok",
148
+ "upstream": client.baseURL + openAPIPath,
149
+ })
150
+ })
151
+
152
+ router.GET(openAPIPath+"/ping", client.handleProxy(http.MethodGet, "/ping"))
153
+ router.GET(openAPIPath+"/quota", client.handleProxy(http.MethodGet, "/quota"))
154
+ router.GET(openAPIPath+"/usage", client.handleProxy(http.MethodGet, "/usage"))
155
+ router.GET(openAPIPath+"/usage/today", client.handleProxy(http.MethodGet, "/usage/today"))
156
+ router.GET(openAPIPath+"/resources/:type/:tmdb_id", client.handleProxy(http.MethodGet, "/resources/:type/:tmdb_id"))
157
+ router.POST(openAPIPath+"/resources/unlock", client.handleUnlock())
158
+ router.POST(openAPIPath+"/check/resource", client.handleProxy(http.MethodPost, "/check/resource"))
159
+
160
+ router.GET(openAPIPath+"/shares", client.handleProxy(http.MethodGet, "/shares"))
161
+ router.POST(openAPIPath+"/shares", client.handleProxy(http.MethodPost, "/shares"))
162
+ router.GET(openAPIPath+"/shares/:slug", client.handleProxy(http.MethodGet, "/shares/:slug"))
163
+ router.PATCH(openAPIPath+"/shares/:slug", client.handleProxy(http.MethodPatch, "/shares/:slug"))
164
+ router.DELETE(openAPIPath+"/shares/:slug", client.handleProxy(http.MethodDelete, "/shares/:slug"))
165
+
166
+ router.GET("/api/tmdb/configuration", client.handleTMDBProxy(http.MethodGet, "/configuration"))
167
+ router.GET("/api/tmdb/configuration/primary_translations", client.handleTMDBProxy(http.MethodGet, "/configuration/primary_translations"))
168
+ router.GET("/api/tmdb/search/:media_type", client.handleTMDBProxy(http.MethodGet, "/search/:media_type"))
169
+ router.GET("/api/tmdb/:media_type/:media_id", client.handleTMDBProxy(http.MethodGet, "/:media_type/:media_id"))
170
+ }
171
+
172
+ func corsMiddleware() gin.HandlerFunc {
173
+ return func(c *gin.Context) {
174
+ headers := c.Writer.Header()
175
+ headers.Set("Access-Control-Allow-Origin", "*")
176
+ headers.Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key, X-TMDB-API-Key")
177
+ headers.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
178
+ headers.Set("Access-Control-Expose-Headers", "Content-Type, X-RateLimit-Reset, X-Endpoint-Limit, X-Endpoint-Remaining, Retry-After")
179
+
180
+ if c.Request.Method == http.MethodOptions {
181
+ c.AbortWithStatus(http.StatusNoContent)
182
+ return
183
+ }
184
+
185
+ c.Next()
186
+ }
187
+ }
188
+
189
+ func (u *upstreamClient) handleProxy(method string, pathTemplate string) gin.HandlerFunc {
190
+ return func(c *gin.Context) {
191
+ body, status, headers, err := u.forwardRequest(
192
+ c.Request.Context(),
193
+ method,
194
+ expandPath(pathTemplate, c),
195
+ copyQuery(c.Request.URL.Query()),
196
+ readRequestBody(c.Request),
197
+ u.resolveAPIKey(c.GetHeader("X-API-Key")),
198
+ c.GetHeader("Content-Type"),
199
+ )
200
+ if err != nil {
201
+ respondLocalError(c, http.StatusBadGateway, "UPSTREAM_REQUEST_FAILED", err.Error(), "")
202
+ return
203
+ }
204
+
205
+ copyResponseHeaders(c.Writer.Header(), headers)
206
+ c.Data(status, contentTypeFromHeaders(headers), body)
207
+ }
208
+ }
209
+
210
+ func (u *upstreamClient) handleUnlock() gin.HandlerFunc {
211
+ return func(c *gin.Context) {
212
+ rawBody := readRequestBody(c.Request)
213
+ if len(rawBody) == 0 {
214
+ respondLocalError(c, http.StatusBadRequest, "400", "request body is required", "请求体不能为空")
215
+ return
216
+ }
217
+
218
+ var req unlockRequest
219
+ if err := json.Unmarshal(rawBody, &req); err != nil {
220
+ respondLocalError(c, http.StatusBadRequest, "400", "invalid json body", "请求体 JSON 格式无效")
221
+ return
222
+ }
223
+
224
+ req.Slug = strings.TrimSpace(req.Slug)
225
+ if req.Slug == "" {
226
+ respondLocalError(c, http.StatusBadRequest, "400", "slug is required", "缺少 slug")
227
+ return
228
+ }
229
+
230
+ apiKey := u.resolveAPIKey(c.GetHeader("X-API-Key"))
231
+ if apiKey == "" {
232
+ respondLocalError(c, http.StatusUnauthorized, "MISSING_API_KEY", "API Key is required", "请通过请求头或环境变量提供 X-API-Key")
233
+ return
234
+ }
235
+
236
+ allowPoints := false
237
+ if req.AllowPoints != nil {
238
+ allowPoints = *req.AllowPoints
239
+ }
240
+
241
+ if !allowPoints {
242
+ detail, errResp := u.lookupShareDetailForUnlock(c.Request.Context(), apiKey, req.Slug)
243
+ if errResp != nil {
244
+ copyResponseHeaders(c.Writer.Header(), errResp.Headers)
245
+ c.Data(errResp.StatusCode, contentTypeFromHeaders(errResp.Headers), errResp.Body)
246
+ return
247
+ }
248
+
249
+ if !isSafeToUnlock(detail) {
250
+ respondLocalError(
251
+ c,
252
+ http.StatusConflict,
253
+ "POINTS_REQUIRED",
254
+ "resource requires points; set allow_points=true to unlock",
255
+ "该资源当前需要扣积分。默认不允许扣积分解锁,请显式传 allow_points=true 后再执行。",
256
+ gin.H{
257
+ "slug": detail.Slug,
258
+ "actual_unlock_points": detail.ActualUnlockPoints,
259
+ "is_unlocked": detail.IsUnlocked,
260
+ "is_free_for_user": detail.IsFreeForUser,
261
+ "unlock_message": detail.UnlockMessage,
262
+ },
263
+ )
264
+ return
265
+ }
266
+ }
267
+
268
+ upstreamBody, _ := json.Marshal(gin.H{"slug": req.Slug})
269
+ body, status, headers, err := u.forwardRequest(
270
+ c.Request.Context(),
271
+ http.MethodPost,
272
+ "/resources/unlock",
273
+ nil,
274
+ upstreamBody,
275
+ apiKey,
276
+ "application/json",
277
+ )
278
+ if err != nil {
279
+ respondLocalError(c, http.StatusBadGateway, "UPSTREAM_REQUEST_FAILED", err.Error(), "")
280
+ return
281
+ }
282
+
283
+ copyResponseHeaders(c.Writer.Header(), headers)
284
+ c.Data(status, contentTypeFromHeaders(headers), body)
285
+ }
286
+ }
287
+
288
+ func (u *upstreamClient) handleTMDBProxy(method string, pathTemplate string) gin.HandlerFunc {
289
+ return func(c *gin.Context) {
290
+ if mediaType := strings.TrimSpace(c.Param("media_type")); mediaType != "" {
291
+ switch mediaType {
292
+ case "movie", "tv", "multi":
293
+ default:
294
+ respondLocalError(c, http.StatusBadRequest, "400", "invalid media_type", "media_type 只能是 movie、tv 或 multi")
295
+ return
296
+ }
297
+ }
298
+
299
+ tmdbKey := u.resolveTMDBAPIKey(c.GetHeader("X-TMDB-API-Key"))
300
+ if tmdbKey == "" {
301
+ respondLocalError(c, http.StatusUnauthorized, "MISSING_TMDB_API_KEY", "TMDB API Key is required", "请通过请求头 X-TMDB-API-Key 或环境变量 TMDB_API_KEY 提供 TMDB API Key")
302
+ return
303
+ }
304
+
305
+ body, status, headers, err := u.forwardTMDBRequest(
306
+ c.Request.Context(),
307
+ method,
308
+ expandPath(pathTemplate, c),
309
+ copyQuery(c.Request.URL.Query()),
310
+ readRequestBody(c.Request),
311
+ tmdbKey,
312
+ c.GetHeader("Content-Type"),
313
+ )
314
+ if err != nil {
315
+ respondLocalError(c, http.StatusBadGateway, "TMDB_REQUEST_FAILED", err.Error(), "")
316
+ return
317
+ }
318
+
319
+ copyResponseHeaders(c.Writer.Header(), headers)
320
+ c.Data(status, contentTypeFromHeaders(headers), body)
321
+ }
322
+ }
323
+
324
+ type proxyResponse struct {
325
+ StatusCode int
326
+ Headers http.Header
327
+ Body []byte
328
+ }
329
+
330
+ func (u *upstreamClient) lookupShareDetailForUnlock(ctx context.Context, apiKey, slug string) (*shareDetailData, *proxyResponse) {
331
+ normalizedSlug := normalizeSlug(slug)
332
+ body, status, headers, err := u.forwardRequest(
333
+ ctx,
334
+ http.MethodGet,
335
+ "/shares/"+url.PathEscape(normalizedSlug),
336
+ nil,
337
+ nil,
338
+ apiKey,
339
+ "",
340
+ )
341
+ if err != nil {
342
+ return nil, &proxyResponse{
343
+ StatusCode: http.StatusBadGateway,
344
+ Headers: http.Header{"Content-Type": []string{"application/json"}},
345
+ Body: mustMarshal(localError{
346
+ Success: false,
347
+ Code: "UPSTREAM_REQUEST_FAILED",
348
+ Message: err.Error(),
349
+ }),
350
+ }
351
+ }
352
+
353
+ if status < http.StatusOK || status >= http.StatusMultipleChoices {
354
+ return nil, &proxyResponse{StatusCode: status, Headers: headers, Body: body}
355
+ }
356
+
357
+ var parsed shareDetailResponse
358
+ if err := json.Unmarshal(body, &parsed); err != nil {
359
+ return nil, &proxyResponse{
360
+ StatusCode: http.StatusBadGateway,
361
+ Headers: http.Header{"Content-Type": []string{"application/json"}},
362
+ Body: mustMarshal(localError{
363
+ Success: false,
364
+ Code: "UPSTREAM_RESPONSE_INVALID",
365
+ Message: "failed to parse share detail response",
366
+ Description: "上游 shares detail 接口返回的数据结构无法解析",
367
+ }),
368
+ }
369
+ }
370
+
371
+ parsed.Data.Slug = normalizedSlug
372
+ return &parsed.Data, nil
373
+ }
374
+
375
+ func (u *upstreamClient) forwardRequest(
376
+ ctx context.Context,
377
+ method string,
378
+ path string,
379
+ query url.Values,
380
+ body []byte,
381
+ apiKey string,
382
+ contentType string,
383
+ ) ([]byte, int, http.Header, error) {
384
+ targetURL := u.baseURL + openAPIPath + path
385
+ if len(query) > 0 {
386
+ targetURL += "?" + query.Encode()
387
+ }
388
+
389
+ var bodyReader io.Reader
390
+ if len(body) > 0 {
391
+ bodyReader = bytes.NewReader(body)
392
+ }
393
+
394
+ req, err := http.NewRequestWithContext(ctx, method, targetURL, bodyReader)
395
+ if err != nil {
396
+ return nil, 0, nil, err
397
+ }
398
+ req.Header.Set("Accept", "application/json")
399
+ if apiKey != "" {
400
+ req.Header.Set("X-API-Key", apiKey)
401
+ }
402
+ if len(body) > 0 {
403
+ if contentType == "" {
404
+ contentType = "application/json"
405
+ }
406
+ req.Header.Set("Content-Type", contentType)
407
+ }
408
+
409
+ resp, err := u.httpClient.Do(req)
410
+ if err != nil {
411
+ return nil, 0, nil, err
412
+ }
413
+ defer resp.Body.Close()
414
+
415
+ respBody, err := io.ReadAll(resp.Body)
416
+ if err != nil {
417
+ return nil, 0, nil, err
418
+ }
419
+
420
+ return respBody, resp.StatusCode, resp.Header.Clone(), nil
421
+ }
422
+
423
+ func (u *upstreamClient) resolveAPIKey(requestKey string) string {
424
+ if strings.TrimSpace(requestKey) != "" {
425
+ return strings.TrimSpace(requestKey)
426
+ }
427
+ return u.defaultAPIKey
428
+ }
429
+
430
+ func (u *upstreamClient) resolveTMDBAPIKey(requestKey string) string {
431
+ if strings.TrimSpace(requestKey) != "" {
432
+ return strings.TrimSpace(requestKey)
433
+ }
434
+ return u.defaultTMDBKey
435
+ }
436
+
437
+ func (u *upstreamClient) forwardTMDBRequest(
438
+ ctx context.Context,
439
+ method string,
440
+ path string,
441
+ query url.Values,
442
+ body []byte,
443
+ apiKey string,
444
+ contentType string,
445
+ ) ([]byte, int, http.Header, error) {
446
+ targetQuery := copyQuery(query)
447
+ if targetQuery == nil {
448
+ targetQuery = make(url.Values)
449
+ }
450
+ targetQuery.Set("api_key", apiKey)
451
+
452
+ targetURL := u.tmdbBaseURL + path
453
+ if len(targetQuery) > 0 {
454
+ targetURL += "?" + targetQuery.Encode()
455
+ }
456
+
457
+ var bodyReader io.Reader
458
+ if len(body) > 0 {
459
+ bodyReader = bytes.NewReader(body)
460
+ }
461
+
462
+ req, err := http.NewRequestWithContext(ctx, method, targetURL, bodyReader)
463
+ if err != nil {
464
+ return nil, 0, nil, err
465
+ }
466
+ req.Header.Set("Accept", "application/json")
467
+ if len(body) > 0 {
468
+ if contentType == "" {
469
+ contentType = "application/json"
470
+ }
471
+ req.Header.Set("Content-Type", contentType)
472
+ }
473
+
474
+ resp, err := u.httpClient.Do(req)
475
+ if err != nil {
476
+ return nil, 0, nil, err
477
+ }
478
+ defer resp.Body.Close()
479
+
480
+ respBody, err := io.ReadAll(resp.Body)
481
+ if err != nil {
482
+ return nil, 0, nil, err
483
+ }
484
+
485
+ return respBody, resp.StatusCode, resp.Header.Clone(), nil
486
+ }
487
+
488
+ func expandPath(template string, c *gin.Context) string {
489
+ result := template
490
+ for _, key := range []string{"type", "tmdb_id", "slug", "media_type", "media_id"} {
491
+ result = strings.ReplaceAll(result, ":"+key, url.PathEscape(c.Param(key)))
492
+ }
493
+ return result
494
+ }
495
+
496
+ func copyQuery(values url.Values) url.Values {
497
+ if values == nil {
498
+ return nil
499
+ }
500
+ cloned := make(url.Values, len(values))
501
+ for key, items := range values {
502
+ cloned[key] = append([]string(nil), items...)
503
+ }
504
+ return cloned
505
+ }
506
+
507
+ func readRequestBody(req *http.Request) []byte {
508
+ if req == nil || req.Body == nil {
509
+ return nil
510
+ }
511
+ body, err := io.ReadAll(req.Body)
512
+ if err != nil {
513
+ return nil
514
+ }
515
+ req.Body = io.NopCloser(bytes.NewReader(body))
516
+ return body
517
+ }
518
+
519
+ func normalizeSlug(slug string) string {
520
+ replacer := strings.NewReplacer("-", "", " ", "")
521
+ return strings.ToLower(strings.TrimSpace(replacer.Replace(slug)))
522
+ }
523
+
524
+ func isSafeToUnlock(detail *shareDetailData) bool {
525
+ if detail == nil {
526
+ return false
527
+ }
528
+ if detail.IsUnlocked || detail.IsFreeForUser {
529
+ return true
530
+ }
531
+ return detail.ActualUnlockPoints <= 0
532
+ }
533
+
534
+ func normalizeTMDBBaseURL(value string) string {
535
+ normalized := strings.TrimSpace(value)
536
+ if normalized == "" {
537
+ normalized = defaultTMDBBaseURL
538
+ }
539
+ normalized = strings.TrimSuffix(normalized, "/")
540
+ if strings.HasSuffix(normalized, "/3") {
541
+ return normalized
542
+ }
543
+ return normalized + "/3"
544
+ }
545
+
546
+ func copyResponseHeaders(dst, src http.Header) {
547
+ if dst == nil || src == nil {
548
+ return
549
+ }
550
+ for _, key := range []string{
551
+ "Content-Type",
552
+ "X-RateLimit-Reset",
553
+ "X-Endpoint-Limit",
554
+ "X-Endpoint-Remaining",
555
+ "Retry-After",
556
+ } {
557
+ values := src.Values(key)
558
+ if len(values) == 0 {
559
+ continue
560
+ }
561
+ dst.Del(key)
562
+ for _, value := range values {
563
+ dst.Add(key, value)
564
+ }
565
+ }
566
+ }
567
+
568
+ func contentTypeFromHeaders(headers http.Header) string {
569
+ if headers == nil {
570
+ return "application/json"
571
+ }
572
+ if value := headers.Get("Content-Type"); value != "" {
573
+ return value
574
+ }
575
+ return "application/json"
576
+ }
577
+
578
+ func respondLocalError(c *gin.Context, status int, code, message, description string, data ...interface{}) {
579
+ resp := localError{
580
+ Success: false,
581
+ Code: code,
582
+ Message: message,
583
+ Description: description,
584
+ }
585
+ if len(data) > 0 {
586
+ resp.Data = data[0]
587
+ }
588
+ c.JSON(status, resp)
589
+ }
590
+
591
+ func mustMarshal(v interface{}) []byte {
592
+ data, err := json.Marshal(v)
593
+ if err != nil {
594
+ panic(err)
595
+ }
596
+ return data
597
+ }
598
+
599
+ func init() {
600
+ gin.SetMode(resolveGinMode())
601
+ }
602
+
603
+ func resolveGinMode() string {
604
+ mode := strings.TrimSpace(os.Getenv("GIN_MODE"))
605
+ switch mode {
606
+ case gin.DebugMode, gin.ReleaseMode, gin.TestMode:
607
+ return mode
608
+ case "":
609
+ return gin.ReleaseMode
610
+ default:
611
+ return gin.ReleaseMode
612
+ }
613
+ }