Skip to content

Commit dcf74ad

Browse files
author
pikachuren
committed
feat(media): add local thumbnail endpoint and cover proxy with fallback gateway
1 parent a4ae0d0 commit dcf74ad

3 files changed

Lines changed: 148 additions & 3 deletions

File tree

‎server/handles/media_extra.go‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,17 @@ type MediaStatus struct {
2727
SubsonicBaseURL string `json:"subsonic_base_url"`
2828
StrmPath string `json:"strm_path"`
2929
StrmOnScan bool `json:"strm_on_scan"`
30+
ThumbPath string `json:"thumb_path"`
3031
}
3132

3233
// PublicMediaStatus 公开的媒体库状态
3334
func PublicMediaStatus(c *gin.Context) {
3435
// Types 必须是非 nil 切片:前端会直接调用 .includes(),nil 序列化成 null 会抛异常
3536
status := MediaStatus{Enabled: MediaEnabled(), Types: []string{}}
37+
apiBase := common.GetApiUrl(c.Request.Context())
38+
status.StrmPath = media.DefaultStrmOptions(apiBase).OutputPath
39+
status.StrmOnScan = setting.GetBool(conf.MediaStrmOnScan)
40+
status.ThumbPath = setting.GetStr(conf.MediaThumbnailPath, "/imgs")
3641
if !status.Enabled {
3742
common.SuccessResp(c, status)
3843
return
@@ -42,11 +47,8 @@ func PublicMediaStatus(c *gin.Context) {
4247
}
4348
status.EmbyEnabled = MediaEmbyEnabled()
4449
status.SubsonicEnabled = MediaSubsonicEnabled()
45-
apiBase := common.GetApiUrl(c.Request.Context())
4650
status.EmbyBaseURL = strings.TrimSuffix(apiBase, "/") + "/emby"
4751
status.SubsonicBaseURL = strings.TrimSuffix(apiBase, "/") + "/rest"
48-
status.StrmPath = media.DefaultStrmOptions(apiBase).OutputPath
49-
status.StrmOnScan = setting.GetBool(conf.MediaStrmOnScan)
5052
common.SuccessResp(c, status)
5153
}
5254

‎server/handles/media_image.go‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package handles
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"mime"
8+
"net/http"
9+
"net/url"
10+
stdpath "path"
11+
"path/filepath"
12+
"strings"
13+
"time"
14+
15+
"github.com/OpenListTeam/OpenList/v4/internal/conf"
16+
"github.com/OpenListTeam/OpenList/v4/internal/setting"
17+
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
18+
"github.com/gin-gonic/gin"
19+
)
20+
21+
// 允许代理的封面来源域名(TMDB / 豆瓣 / Discogs)。
22+
// 仅代理图片,避免该接口被当作通用反向代理滥用。
23+
var coverAllowedHosts = []string{
24+
"image.tmdb.org",
25+
"tmdb.org",
26+
"doubanio.com",
27+
"douban.com",
28+
"discogs.com",
29+
}
30+
31+
func hostAllowed(host string) bool {
32+
host = strings.ToLower(host)
33+
for _, h := range coverAllowedHosts {
34+
if host == h || strings.HasSuffix(host, "."+h) {
35+
return true
36+
}
37+
}
38+
return false
39+
}
40+
41+
// PublicMediaProxy 代理外部封面图(TMDB/豆瓣/Discogs),解决客户端直连被墙的问题。
42+
// 用法:/api/public/media/proxy?url=https%3A%2F%2Fimage.tmdb.org%2F...
43+
func PublicMediaProxy(c *gin.Context) {
44+
raw := c.Query("url")
45+
if raw == "" {
46+
c.Status(http.StatusBadRequest)
47+
return
48+
}
49+
u, err := url.Parse(raw)
50+
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || !hostAllowed(u.Hostname()) {
51+
c.Status(http.StatusForbidden)
52+
return
53+
}
54+
resp, err := fetchCover(c.Request.Context(), u.String())
55+
if err != nil {
56+
// 直连失败时回退到公共图片网关(部分网络环境下 TMDB 图片域名不可达)
57+
gateway := "https://wsrv.nl/?url=" + url.QueryEscape(strings.TrimPrefix(strings.TrimPrefix(u.String(), "https://"), "http://"))
58+
resp, err = fetchCover(c.Request.Context(), gateway)
59+
}
60+
if err != nil {
61+
utils.Log.Warnf("media cover proxy failed: %s: %v", u.String(), err)
62+
c.Status(http.StatusBadGateway)
63+
return
64+
}
65+
defer resp.Body.Close()
66+
ct := resp.Header.Get("Content-Type")
67+
if ct == "" {
68+
ct = "image/jpeg"
69+
}
70+
if !strings.HasPrefix(ct, "image/") {
71+
c.Status(http.StatusUnsupportedMediaType)
72+
return
73+
}
74+
c.Header("Content-Type", ct)
75+
c.Header("Cache-Control", "public, max-age=604800")
76+
_, _ = io.Copy(c.Writer, io.LimitReader(resp.Body, 8<<20))
77+
}
78+
79+
// fetchCover 拉取图片,仅接受 200 且 Content-Type 为 image/*
80+
func fetchCover(ctx context.Context, raw string) (*http.Response, error) {
81+
client := &http.Client{Timeout: 20 * time.Second}
82+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
83+
if err != nil {
84+
return nil, err
85+
}
86+
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; OpenList-Media/1.0)")
87+
resp, err := client.Do(req)
88+
if err != nil {
89+
return nil, err
90+
}
91+
if resp.StatusCode != http.StatusOK {
92+
_ = resp.Body.Close()
93+
return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
94+
}
95+
return resp, nil
96+
}
97+
98+
// thumbnailRoot 返回本地缩略图目录的绝对路径(与刮削写入时保持一致)
99+
func thumbnailRoot() string {
100+
dir := setting.GetStr(conf.MediaThumbnailPath, "/imgs")
101+
if !filepath.IsAbs(dir) {
102+
if wd, err := filepath.Abs("."); err == nil {
103+
dir = filepath.Join(wd, dir)
104+
}
105+
}
106+
return filepath.Clean(dir)
107+
}
108+
109+
// PublicMediaThumb 读取本地缩略图目录中的文件。
110+
// 缩略图由刮削器写入本地磁盘(非 VFS),因此不能用 /d 读取。
111+
// 用法:/api/public/media/thumb?path=/imgs/xxx.jpg
112+
func PublicMediaThumb(c *gin.Context) {
113+
reqPath := strings.TrimPrefix(stdpath.Clean("/"+c.Query("path")), "/")
114+
if reqPath == "" || reqPath == "." || strings.Contains(reqPath, "..") {
115+
c.Status(http.StatusBadRequest)
116+
return
117+
}
118+
base := strings.Trim(setting.GetStr(conf.MediaThumbnailPath, "/imgs"), "/")
119+
if base != "" {
120+
if !strings.HasPrefix(reqPath, base+"/") {
121+
c.Status(http.StatusNotFound)
122+
return
123+
}
124+
reqPath = strings.TrimPrefix(reqPath, base+"/")
125+
}
126+
root := thumbnailRoot()
127+
full := filepath.Join(root, filepath.FromSlash(reqPath))
128+
rel, err := filepath.Rel(root, full)
129+
if err != nil || strings.HasPrefix(rel, "..") {
130+
c.Status(http.StatusForbidden)
131+
return
132+
}
133+
ct := mime.TypeByExtension(filepath.Ext(full))
134+
if ct == "" {
135+
ct = "image/jpeg"
136+
}
137+
c.Header("Content-Type", ct)
138+
c.Header("Cache-Control", "public, max-age=604800")
139+
c.File(full)
140+
}

‎server/router.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ func Init(e *gin.Engine) {
110110
public := api.Group("/public")
111111
public.Any("/settings", handles.PublicSettings)
112112
public.GET("/media_status", handles.PublicMediaStatus)
113+
// 封面代理与本地缩略图(媒体库页面使用,免鉴权)
114+
public.GET("/media/proxy", handles.PublicMediaProxy)
115+
public.GET("/media/thumb", handles.PublicMediaThumb)
113116
public.Any("/offline_download_tools", handles.OfflineDownloadTools)
114117
public.Any("/archive_extensions", handles.ArchiveExtensions)
115118
public.Any("/init_status", handles.InitStatus)

0 commit comments

Comments
 (0)