mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 03:36:37 +08:00
fix: harden discover artwork caching
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -14,6 +15,8 @@ import (
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service/cloud"
|
||||
)
|
||||
|
||||
var handlerTestJPEG = []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00, 0xff, 0xd9}
|
||||
|
||||
func TestCloudMountLibraryNameDefaultsToDirectoryBaseName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -96,7 +99,7 @@ func TestCloudArtworkProxyServesCachedImageWithoutCloudResolve(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("cached-cloud-poster"))
|
||||
_, _ = w.Write(handlerTestJPEG)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
@@ -116,7 +119,7 @@ func TestCloudArtworkProxyServesCachedImageWithoutCloudResolve(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s, want 200", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Body.String(); got != "cached-cloud-poster" {
|
||||
if got := w.Body.Bytes(); !bytes.Equal(got, handlerTestJPEG) {
|
||||
t.Fatalf("body = %q, want cached poster", got)
|
||||
}
|
||||
if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "max-age=2592000") {
|
||||
@@ -128,7 +131,7 @@ func TestCloudArtworkProxyAcceptsCachedTBNImage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("cached-tbn-poster"))
|
||||
_, _ = w.Write(handlerTestJPEG)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
@@ -148,7 +151,7 @@ func TestCloudArtworkProxyAcceptsCachedTBNImage(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s, want 200", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Body.String(); got != "cached-tbn-poster" {
|
||||
if got := w.Body.Bytes(); !bytes.Equal(got, handlerTestJPEG) {
|
||||
t.Fatalf("body = %q, want cached tbn poster", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -95,7 +96,7 @@ func TestEmbyItemImageServesCachedCloudArtworkWithoutResolve(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("emby-cached-cloud-poster"))
|
||||
_, _ = w.Write(handlerTestJPEG)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
@@ -137,7 +138,7 @@ func TestEmbyItemImageServesCachedCloudArtworkWithoutResolve(t *testing.T) {
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Body.String(); got != "emby-cached-cloud-poster" {
|
||||
if got := w.Body.Bytes(); !bytes.Equal(got, handlerTestJPEG) {
|
||||
t.Fatalf("body = %q, want cached cloud poster", got)
|
||||
}
|
||||
if location := w.Header().Get("Location"); location != "" {
|
||||
|
||||
@@ -70,7 +70,9 @@ func stopTranscodeHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func imageProxyHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
raw := c.Query("url")
|
||||
if c.Query("retry") != "" || c.Query("refresh") != "" {
|
||||
if c.Query("refresh") != "" {
|
||||
_ = svc.ImageProxy.RemoveFailed(raw)
|
||||
} else if c.Query("retry") != "" {
|
||||
_ = svc.ImageProxy.RemoveFailed(raw)
|
||||
}
|
||||
// Serve handles upstream errors internally by returning a 1×1 PNG
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -41,7 +41,7 @@ func TestDiscoverWarmExternalArtworkPrefetchesAndCaches(t *testing.T) {
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"image/jpeg"}},
|
||||
Body: io.NopCloser(strings.NewReader("image:" + req.URL.Path)),
|
||||
Body: io.NopCloser(bytes.NewReader(testJPEG)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
|
||||
@@ -43,7 +43,7 @@ type ImageProxy struct {
|
||||
|
||||
const (
|
||||
imageBrowserCacheControl = "public, max-age=2592000, immutable"
|
||||
imagePlaceholderCacheControl = "public, max-age=3600"
|
||||
imagePlaceholderCacheControl = "no-store"
|
||||
imageNegativeCacheTTL = 6 * time.Hour
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
@@ -34,6 +35,23 @@ func detectContentType(data []byte) string {
|
||||
return http.DetectContentType(data)
|
||||
}
|
||||
|
||||
func isImageContentType(ctype string) bool {
|
||||
ctype = strings.ToLower(strings.TrimSpace(strings.Split(ctype, ";")[0]))
|
||||
return strings.HasPrefix(ctype, "image/")
|
||||
}
|
||||
|
||||
func validImageContentType(data []byte) (string, bool) {
|
||||
detected := detectContentType(data)
|
||||
if isImageContentType(detected) && !isTransparentPlaceholderData(data) {
|
||||
return detected, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func isTransparentPlaceholderData(data []byte) bool {
|
||||
return bytes.Equal(data, transparent1x1PNG)
|
||||
}
|
||||
|
||||
// servePlaceholder writes a 1x1 transparent PNG to w. Used as a fallback
|
||||
// when upstream fetch fails so the browser layout stays intact.
|
||||
func servePlaceholder(w http.ResponseWriter) {
|
||||
@@ -93,7 +111,11 @@ func serveImageFile(w http.ResponseWriter, r *http.Request, key, path, cacheCont
|
||||
var sample [512]byte
|
||||
n, _ := file.Read(sample[:])
|
||||
_, _ = file.Seek(0, io.SeekStart)
|
||||
w.Header().Set("Content-Type", detectContentType(sample[:n]))
|
||||
ctype := detectContentType(sample[:n])
|
||||
if !isImageContentType(ctype) {
|
||||
return false
|
||||
}
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
w.Header().Set("Cache-Control", cacheControl)
|
||||
w.Header().Set("ETag", imageFileETag(key, stat))
|
||||
http.ServeContent(w, r, key, stat.ModTime(), file)
|
||||
|
||||
@@ -23,8 +23,12 @@ func (p *ImageProxy) CloudImageCached(stableKey string) bool {
|
||||
return false
|
||||
}
|
||||
_, cachePath, failPath := p.cloudImageCachePaths(stableKey)
|
||||
if stat, err := os.Stat(cachePath); err == nil && stat.Size() > 0 {
|
||||
return true
|
||||
if data, err := os.ReadFile(cachePath); err == nil {
|
||||
if _, ok := validImageContentType(data); ok {
|
||||
return true
|
||||
}
|
||||
_ = os.Remove(cachePath)
|
||||
_ = os.Remove(failPath)
|
||||
}
|
||||
return freshNegativeImageCache(failPath)
|
||||
}
|
||||
@@ -37,6 +41,7 @@ func (p *ImageProxy) ServeCloudCached(w http.ResponseWriter, r *http.Request, st
|
||||
return false
|
||||
}
|
||||
key, cachePath, failPath := p.cloudImageCachePaths(stableKey)
|
||||
p.removeUnusableImageCache(cachePath, failPath)
|
||||
if serveCachedImageFile(w, r, key, cachePath) {
|
||||
return true
|
||||
}
|
||||
@@ -59,6 +64,7 @@ func (p *ImageProxy) ServeCloudResolved(ctx context.Context, w http.ResponseWrit
|
||||
stableKey = link.URL
|
||||
}
|
||||
key, cachePath, failPath := p.cloudImageCachePaths(stableKey)
|
||||
p.removeUnusableImageCache(cachePath, failPath)
|
||||
if serveCachedImageFile(w, r, key, cachePath) {
|
||||
return nil
|
||||
}
|
||||
@@ -135,10 +141,11 @@ func (p *ImageProxy) fetchAndCacheCloudImage(ctx context.Context, stableKey stri
|
||||
p.markImageFetchFailed(failPath)
|
||||
return nil, "", errors.New("cloud image body is empty")
|
||||
}
|
||||
p.writeImageCache(cachePath, failPath, "img-cloud-*.tmp", data)
|
||||
ctype := resp.Header.Get("Content-Type")
|
||||
if ctype == "" {
|
||||
ctype = detectContentType(data)
|
||||
ctype, ok := validImageContentType(data)
|
||||
if !ok {
|
||||
p.markImageFetchFailed(failPath)
|
||||
return nil, "", errors.New("cloud image returned non-image content")
|
||||
}
|
||||
p.writeImageCache(cachePath, failPath, "img-cloud-*.tmp", data)
|
||||
return data, ctype, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -15,6 +16,12 @@ import (
|
||||
)
|
||||
|
||||
var errImageProxyRequestSetup = errors.New("image proxy request setup failed")
|
||||
var errImageProxyNonImageContent = errors.New("upstream returned non-image content")
|
||||
|
||||
type remoteImageFetchClient struct {
|
||||
name string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (p *ImageProxy) PrefetchRemote(ctx context.Context, raw string) error {
|
||||
_, _, err := p.Fetch(ctx, raw)
|
||||
@@ -81,14 +88,19 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
|
||||
}
|
||||
host := strings.ToLower(u.Host)
|
||||
key, cachePath, failPath := p.remoteImageCachePathsForValidated(raw)
|
||||
if serveCachedImageFile(w, r, key, cachePath) {
|
||||
forceRefresh := r.URL.Query().Get("refresh") != ""
|
||||
p.removeUnusableImageCache(cachePath, failPath)
|
||||
if !forceRefresh && serveCachedImageFile(w, r, key, cachePath) {
|
||||
return nil
|
||||
}
|
||||
if p.serveFreshRemoteFailure(w, failPath) {
|
||||
if !forceRefresh && p.serveFreshRemoteFailure(w, failPath) {
|
||||
return nil
|
||||
}
|
||||
data, ctype, contentLength, err := p.fetchAndCacheRemoteImage(ctx, raw, host, cachePath, failPath)
|
||||
if err != nil {
|
||||
if forceRefresh && serveCachedImageFile(w, r, key, cachePath) {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, errImageProxyRequestSetup) {
|
||||
servePlaceholder(w)
|
||||
} else {
|
||||
@@ -110,6 +122,19 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ImageProxy) removeUnusableImageCache(cachePath, failPath string) {
|
||||
data, err := os.ReadFile(cachePath) // #nosec G304 -- cachePath is SHA-derived under cacheDir.
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ctype := detectContentType(data)
|
||||
if len(data) > 0 && isImageContentType(ctype) && !isTransparentPlaceholderData(data) {
|
||||
return
|
||||
}
|
||||
_ = os.Remove(cachePath)
|
||||
_ = os.Remove(failPath)
|
||||
}
|
||||
|
||||
func (p *ImageProxy) serveFreshRemoteFailure(w http.ResponseWriter, failPath string) bool {
|
||||
if stat, err := os.Stat(failPath); err == nil && time.Since(stat.ModTime()) < imageNegativeCacheTTL {
|
||||
serveCachedPlaceholder(w)
|
||||
@@ -125,60 +150,196 @@ func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, ca
|
||||
p.log.Warn("imageproxy: mkdir failed", zap.String("dir", p.cacheDir), zap.Error(err))
|
||||
return nil, "", "", errImageProxyRequestSetup
|
||||
}
|
||||
var lastErr error
|
||||
for _, candidate := range p.remoteImageFetchClients() {
|
||||
data, ctype, contentLength, err := p.fetchRemoteImageOnce(ctx, raw, host, candidate)
|
||||
if err == nil {
|
||||
p.writeImageCache(cachePath, failPath, "img-*.tmp", data)
|
||||
return data, ctype, contentLength, nil
|
||||
}
|
||||
if errors.Is(err, errImageProxyRequestSetup) {
|
||||
return nil, "", "", err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if p.canUseExternalImageFallback() && isDoubanImageHost(host) {
|
||||
data, ctype, contentLength, err := fetchRemoteImageWithCurl(ctx, raw, host)
|
||||
if err == nil {
|
||||
p.writeImageCache(cachePath, failPath, "img-*.tmp", data)
|
||||
return data, ctype, contentLength, nil
|
||||
}
|
||||
p.log.Warn("imageproxy: curl fallback failed", zap.String("host", host), zap.Error(err))
|
||||
lastErr = err
|
||||
}
|
||||
p.markImageFetchFailed(failPath)
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("upstream image fetch failed")
|
||||
}
|
||||
return nil, "", "", lastErr
|
||||
}
|
||||
|
||||
func (p *ImageProxy) remoteImageFetchClients() []remoteImageFetchClient {
|
||||
client := p.client
|
||||
if client == nil {
|
||||
client = NewExternalHTTPClient(30 * time.Second)
|
||||
}
|
||||
clients := []remoteImageFetchClient{{name: "default", client: client}}
|
||||
if _, ok := client.Transport.(*http.Transport); ok {
|
||||
timeout := client.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
clients = append(clients, remoteImageFetchClient{
|
||||
name: "direct",
|
||||
client: &http.Client{Timeout: timeout, Transport: NewInternalTransport()},
|
||||
})
|
||||
}
|
||||
return clients
|
||||
}
|
||||
|
||||
func (p *ImageProxy) canUseExternalImageFallback() bool {
|
||||
if p == nil || p.client == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := p.client.Transport.(*http.Transport)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string, candidate remoteImageFetchClient) ([]byte, string, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||
if err != nil {
|
||||
p.log.Warn("imageproxy: build request failed", zap.String("url", raw), zap.Error(err))
|
||||
return nil, "", "", errImageProxyRequestSetup
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
||||
if strings.Contains(host, "doubanio.com") {
|
||||
req.Header.Set("Referer", "https://movie.douban.com/")
|
||||
}
|
||||
resp, err := p.client.Do(req)
|
||||
applyRemoteImageHeaders(req, host)
|
||||
|
||||
resp, err := candidate.client.Do(req)
|
||||
if err != nil {
|
||||
p.log.Warn("imageproxy: upstream fetch failed", zap.String("host", host), zap.Error(err))
|
||||
p.markImageFetchFailed(failPath)
|
||||
p.log.Warn("imageproxy: upstream fetch failed", zap.String("host", host), zap.String("client", candidate.name), zap.Error(err))
|
||||
return nil, "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
p.log.Warn("imageproxy: upstream returned non-OK", zap.String("host", host), zap.String("status", resp.Status))
|
||||
p.markImageFetchFailed(failPath)
|
||||
p.log.Warn("imageproxy: upstream returned non-OK", zap.String("host", host), zap.String("client", candidate.name), zap.String("status", resp.Status))
|
||||
return nil, "", "", errors.New("upstream returned " + resp.Status)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
if err != nil || len(data) == 0 {
|
||||
p.log.Warn("imageproxy: read upstream body failed", zap.String("host", host), zap.Error(err))
|
||||
p.markImageFetchFailed(failPath)
|
||||
p.log.Warn("imageproxy: read upstream body failed", zap.String("host", host), zap.String("client", candidate.name), zap.Error(err))
|
||||
if err == nil {
|
||||
err = errors.New("upstream image body is empty")
|
||||
}
|
||||
return nil, "", "", err
|
||||
}
|
||||
p.writeImageCache(cachePath, failPath, "img-*.tmp", data)
|
||||
ctype := resp.Header.Get("Content-Type")
|
||||
if ctype == "" {
|
||||
ctype = detectContentType(data)
|
||||
ctype, ok := validImageContentType(data)
|
||||
if !ok {
|
||||
p.log.Warn("imageproxy: upstream returned non-image content", zap.String("host", host), zap.String("client", candidate.name), zap.String("content_type", resp.Header.Get("Content-Type")))
|
||||
return nil, "", "", errImageProxyNonImageContent
|
||||
}
|
||||
return data, ctype, resp.Header.Get("Content-Length"), nil
|
||||
}
|
||||
|
||||
func applyRemoteImageHeaders(req *http.Request, host string) {
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36")
|
||||
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
|
||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
req.Header.Set("Pragma", "no-cache")
|
||||
switch {
|
||||
case strings.Contains(host, "doubanio.com"):
|
||||
req.Header.Set("Referer", "https://movie.douban.com/")
|
||||
case strings.Contains(host, "bgm.tv"):
|
||||
req.Header.Set("Referer", "https://bgm.tv/")
|
||||
}
|
||||
}
|
||||
|
||||
func isDoubanImageHost(host string) bool {
|
||||
return strings.Contains(strings.ToLower(host), "doubanio.com")
|
||||
}
|
||||
|
||||
func fetchRemoteImageWithCurl(ctx context.Context, raw, host string) ([]byte, string, string, error) {
|
||||
bin, err := exec.LookPath("curl")
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
curlCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
args := []string{
|
||||
"--fail",
|
||||
"--location",
|
||||
"--silent",
|
||||
"--show-error",
|
||||
"--http1.1",
|
||||
"--max-time", "20",
|
||||
"--proto", "=http,https",
|
||||
"--proto-redir", "=http,https",
|
||||
"--user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36",
|
||||
"--header", "Accept: image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||||
"--header", "Accept-Language: zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"--header", "Cache-Control: no-cache",
|
||||
"--header", "Pragma: no-cache",
|
||||
}
|
||||
if isDoubanImageHost(host) {
|
||||
args = append(args, "--referer", "https://movie.douban.com/")
|
||||
}
|
||||
args = append(args, "--", raw)
|
||||
|
||||
cmd := exec.CommandContext(curlCtx, bin, args...) // #nosec G204 -- bin is resolved by LookPath and args are not shell-expanded.
|
||||
stderr := bytes.Buffer{}
|
||||
cmd.Stderr = &stderr
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
data, readErr := io.ReadAll(io.LimitReader(stdout, 32<<20))
|
||||
waitErr := cmd.Wait()
|
||||
if readErr != nil {
|
||||
return nil, "", "", readErr
|
||||
}
|
||||
if waitErr != nil {
|
||||
message := strings.TrimSpace(stderr.String())
|
||||
if message != "" {
|
||||
return nil, "", "", errors.New(message)
|
||||
}
|
||||
return nil, "", "", waitErr
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, "", "", errors.New("curl image body is empty")
|
||||
}
|
||||
ctype := detectContentType(data)
|
||||
if !isImageContentType(ctype) || isTransparentPlaceholderData(data) {
|
||||
return nil, "", "", errors.New("curl returned non-image content")
|
||||
}
|
||||
return data, ctype, "", nil
|
||||
}
|
||||
|
||||
// Fetch pulls a remote image and returns bytes plus Content-Type using cache.
|
||||
func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, error) {
|
||||
if _, err := p.validateURL(raw); err != nil {
|
||||
u, err := p.validateURL(raw)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
host := strings.ToLower(u.Host)
|
||||
_, cachePath, failPath := p.remoteImageCachePathsForValidated(raw)
|
||||
p.removeUnusableImageCache(cachePath, failPath)
|
||||
if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 { // #nosec G304 -- cachePath is SHA-derived under cacheDir.
|
||||
return data, detectContentType(data), nil
|
||||
ctype := detectContentType(data)
|
||||
if isImageContentType(ctype) && !isTransparentPlaceholderData(data) {
|
||||
return data, ctype, nil
|
||||
}
|
||||
_ = os.Remove(cachePath)
|
||||
_ = os.Remove(failPath)
|
||||
}
|
||||
if stat, err := os.Stat(failPath); err == nil && time.Since(stat.ModTime()) < imageNegativeCacheTTL {
|
||||
return nil, "", errors.New("recent image fetch failure")
|
||||
} else if err == nil {
|
||||
_ = os.Remove(failPath)
|
||||
}
|
||||
data, ctype, _, err := p.fetchAndCacheRemoteImage(ctx, raw, "", cachePath, failPath)
|
||||
data, ctype, _, err := p.fetchAndCacheRemoteImage(ctx, raw, host, cachePath, failPath)
|
||||
return data, ctype, err
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -16,6 +18,12 @@ import (
|
||||
"github.com/ShukeBta/MediaStationGo/internal/service/cloud"
|
||||
)
|
||||
|
||||
var testJPEG = []byte{
|
||||
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F',
|
||||
0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01,
|
||||
0x00, 0x00, 0xff, 0xd9,
|
||||
}
|
||||
|
||||
func TestImageProxyServesLocalImagePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
imagePath := filepath.Join(dir, "episode-thumb.png")
|
||||
@@ -51,7 +59,7 @@ func TestImageProxyServesPosterUnderLibraryRoot(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Dir(posterPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
realPoster := []byte("THIS-IS-A-REAL-POSTER-NOT-THE-PLACEHOLDER")
|
||||
realPoster := testJPEG
|
||||
if err := os.WriteFile(posterPath, realPoster, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -77,8 +85,8 @@ func TestImageProxyServesPosterUnderLibraryRoot(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
if got := rec.Body.Bytes(); string(got) != string(realPoster) {
|
||||
t.Fatalf("served %q, want real poster bytes", string(got))
|
||||
if got := rec.Body.Bytes(); !bytes.Equal(got, realPoster) {
|
||||
t.Fatalf("served %x, want real poster bytes", got)
|
||||
}
|
||||
etag := rec.Header().Get("ETag")
|
||||
if etag == "" {
|
||||
@@ -150,7 +158,7 @@ func TestImageProxyRemoveFailedAllowsRetry(t *testing.T) {
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"image/jpeg"}},
|
||||
Body: io.NopCloser(strings.NewReader("poster-bytes")),
|
||||
Body: io.NopCloser(bytes.NewReader(testJPEG)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
@@ -170,14 +178,217 @@ func TestImageProxyRemoveFailedAllowsRetry(t *testing.T) {
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img?v=retry", nil), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := rec.Body.String(); got != "poster-bytes" {
|
||||
t.Fatalf("retried body = %q, want poster bytes", got)
|
||||
if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) {
|
||||
t.Fatalf("retried body = %x, want poster bytes", got)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||
t.Fatalf("upstream calls = %d, want 2 after retry", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageProxyPrefetchRemoteUsesProviderHeaders(t *testing.T) {
|
||||
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||
proxy.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if got := req.Header.Get("Referer"); got != "https://movie.douban.com/" {
|
||||
t.Fatalf("Referer = %q, want Douban movie referer", got)
|
||||
}
|
||||
if got := req.Header.Get("User-Agent"); !strings.Contains(got, "Mozilla/5.0") {
|
||||
t.Fatalf("User-Agent = %q, want browser-like UA", got)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"image/jpeg"}},
|
||||
Body: io.NopCloser(bytes.NewReader(testJPEG)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
|
||||
raw := "https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2933012346.jpg"
|
||||
if err := proxy.PrefetchRemote(t.Context(), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageProxyRemoveCachedAllowsRefresh(t *testing.T) {
|
||||
var calls int32
|
||||
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||
proxy.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"image/png"}},
|
||||
Body: io.NopCloser(bytes.NewReader(testJPEG)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
|
||||
raw := "https://image.tmdb.org/t/p/w500/refresh-poster.jpg"
|
||||
if err := proxy.PrefetchRemote(t.Context(), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := proxy.RemoveCached(raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("upstream calls before refresh = %d, want 1", got)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img?refresh=1", nil), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||
t.Fatalf("upstream calls after refresh = %d, want 2", got)
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageProxyRefreshKeepsCachedImageOnUpstreamFailure(t *testing.T) {
|
||||
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||
proxy.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Status: "502 Bad Gateway",
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader("upstream unavailable")),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
|
||||
raw := "https://image.tmdb.org/t/p/w500/cached-poster.jpg"
|
||||
_, cachePath, _, err := proxy.remoteImageCachePaths(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cachedPoster := testJPEG
|
||||
if err := os.WriteFile(cachePath, cachedPoster, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img?refresh=1", nil), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := rec.Body.Bytes(); !bytes.Equal(got, cachedPoster) {
|
||||
t.Fatalf("body = %x, want cached poster after failed refresh", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageProxyRefetchesTransparentPlaceholderCache(t *testing.T) {
|
||||
var calls int32
|
||||
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||
proxy.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"image/jpeg"}},
|
||||
Body: io.NopCloser(bytes.NewReader(testJPEG)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
|
||||
raw := "https://img1.doubanio.com/view/photo/s_ratio_poster/public/p2925358079.jpg"
|
||||
_, cachePath, failPath, err := proxy.remoteImageCachePaths(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(cachePath, transparent1x1PNG, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(failPath, []byte("failed"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img?retry=1", nil), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) {
|
||||
t.Fatalf("body = %x, want refetched poster bytes", got)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageProxyDoesNotCacheNonImageRemoteResponse(t *testing.T) {
|
||||
var calls int32
|
||||
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||
proxy.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"text/html"}},
|
||||
Body: io.NopCloser(strings.NewReader("<html>not image</html>")),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
|
||||
raw := "https://img1.doubanio.com/view/photo/s_ratio_poster/public/p-bad.jpg"
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Body.Len() != len(transparent1x1PNG) {
|
||||
t.Fatalf("body length = %d, want placeholder %d", rec.Body.Len(), len(transparent1x1PNG))
|
||||
}
|
||||
_, cachePath, _, err := proxy.remoteImageCachePaths(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(cachePath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("non-image response should not be cached, stat err=%v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageProxyDoesNotCacheMislabeledRemoteResponse(t *testing.T) {
|
||||
var calls int32
|
||||
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||
proxy.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"image/jpeg"}},
|
||||
Body: io.NopCloser(strings.NewReader("<html>not a poster</html>")),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
|
||||
raw := "https://img1.doubanio.com/view/photo/s_ratio_poster/public/p-mislabeled.jpg"
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec.Body.Len() != len(transparent1x1PNG) {
|
||||
t.Fatalf("body length = %d, want placeholder %d", rec.Body.Len(), len(transparent1x1PNG))
|
||||
}
|
||||
_, cachePath, _, err := proxy.remoteImageCachePaths(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(cachePath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("mislabeled non-image response should not be cached, stat err=%v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageProxyCachesCloudResolvedImage(t *testing.T) {
|
||||
var calls int32
|
||||
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||
@@ -187,7 +398,7 @@ func TestImageProxyCachesCloudResolvedImage(t *testing.T) {
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"image/png"}},
|
||||
Body: io.NopCloser(strings.NewReader(string(transparent1x1PNG))),
|
||||
Body: io.NopCloser(bytes.NewReader(testJPEG)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
@@ -219,8 +430,8 @@ func TestImageProxyCachesCloudResolvedImage(t *testing.T) {
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("upstream calls after ServeCloudCached = %d, want 1", got)
|
||||
}
|
||||
if rec.Body.Len() != len(transparent1x1PNG) {
|
||||
t.Fatalf("cached body length = %d, want %d", rec.Body.Len(), len(transparent1x1PNG))
|
||||
if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) {
|
||||
t.Fatalf("cached body = %x, want cached cloud image", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +444,7 @@ func TestImageProxyPrefetchCloudResolvedImage(t *testing.T) {
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"image/png"}},
|
||||
Body: io.NopCloser(strings.NewReader(string(transparent1x1PNG))),
|
||||
Body: io.NopCloser(bytes.NewReader(testJPEG)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
@@ -252,8 +463,76 @@ func TestImageProxyPrefetchCloudResolvedImage(t *testing.T) {
|
||||
if !proxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, "/api/cloud/play/openlist?ref=folder.png", nil), "openlist:folder.png") {
|
||||
t.Fatal("prefetched cloud image was not served from cache")
|
||||
}
|
||||
if rec.Body.Len() != len(transparent1x1PNG) {
|
||||
t.Fatalf("cached body length = %d, want %d", rec.Body.Len(), len(transparent1x1PNG))
|
||||
if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) {
|
||||
t.Fatalf("cached body = %x, want prefetched cloud image", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageProxyPrefetchCloudResolvedRefetchesInvalidCache(t *testing.T) {
|
||||
var calls int32
|
||||
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||
proxy.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"image/jpeg"}},
|
||||
Body: io.NopCloser(bytes.NewReader(testJPEG)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
|
||||
stableKey := "openlist:bad-cache-poster.jpg"
|
||||
_, cachePath, failPath := proxy.cloudImageCachePaths(stableKey)
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(cachePath, []byte("<html>old bad cache</html>"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(failPath, []byte("failed"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
link := &cloud.DirectLink{URL: "http://cloud-provider.invalid/bad-cache-poster.jpg"}
|
||||
if err := proxy.PrefetchCloudResolved(t.Context(), stableKey, link); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("upstream calls = %d, want 1 after invalid cache cleanup", got)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
if !proxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, "/api/cloud/play/openlist?ref=bad-cache-poster.jpg", nil), stableKey) {
|
||||
t.Fatal("refetched cloud image was not served from cache")
|
||||
}
|
||||
if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) {
|
||||
t.Fatalf("cached body = %x, want refetched cloud image", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageProxyServeCloudCachedSkipsInvalidCache(t *testing.T) {
|
||||
proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||
stableKey := "openlist:invalid-cached-poster.jpg"
|
||||
_, cachePath, failPath := proxy.cloudImageCachePaths(stableKey)
|
||||
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(cachePath, transparent1x1PNG, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(failPath, []byte("failed"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if proxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, "/api/cloud/play/openlist?ref=invalid-cached-poster.jpg", nil), stableKey) {
|
||||
t.Fatal("ServeCloudCached should skip invalid cached cloud artwork")
|
||||
}
|
||||
if _, err := os.Stat(cachePath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("invalid cache should be removed, stat err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(failPath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("stale fail marker should be removed with invalid cache, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ func NewInternalHTTPClient(timeout time.Duration) *http.Client {
|
||||
func NewExternalTransport() *http.Transport {
|
||||
return &http.Transport{
|
||||
Proxy: ProxyFromEnvironmentOrSystem,
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 32,
|
||||
MaxIdleConnsPerHost: 8,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -34,7 +35,7 @@ func TestScanCloudLibraryCachesFileLevelRemoteArtwork(t *testing.T) {
|
||||
_, _ = w.Write([]byte(`<movie><title>Sidecar Movie</title><year>2026</year></movie>`))
|
||||
case "/dav/Movies/Movie.jpg":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("file-level-poster"))
|
||||
_, _ = w.Write(testJPEG)
|
||||
default:
|
||||
t.Fatalf("unexpected get path %s", r.URL.Path)
|
||||
}
|
||||
@@ -86,8 +87,8 @@ func TestScanCloudLibraryCachesFileLevelRemoteArtwork(t *testing.T) {
|
||||
if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Movie.jpg") {
|
||||
t.Fatal("file-level cloud poster should be cached locally during scan before media is exposed")
|
||||
}
|
||||
if got := rec.Body.String(); got != "file-level-poster" {
|
||||
t.Fatalf("cached poster body = %q", got)
|
||||
if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) {
|
||||
t.Fatalf("cached poster body = %x", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,10 +115,10 @@ func TestScanCloudLibraryUsesArtworkReferencedByRemoteNFO(t *testing.T) {
|
||||
_, _ = w.Write([]byte(`<movie><title>NFO Custom Artwork</title><thumb aspect="poster">Artwork.Custom.tbn</thumb><fanart><thumb>Scene.Still.png?version=1</thumb></fanart></movie>`))
|
||||
case "/dav/Movies/Artwork.Custom.tbn":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("custom-poster"))
|
||||
_, _ = w.Write(testJPEG)
|
||||
case "/dav/Movies/Scene.Still.png":
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write([]byte("custom-backdrop"))
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(testJPEG)
|
||||
default:
|
||||
t.Fatalf("unexpected get path %s", r.URL.Path)
|
||||
}
|
||||
@@ -172,8 +173,8 @@ func TestScanCloudLibraryUsesArtworkReferencedByRemoteNFO(t *testing.T) {
|
||||
if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Artwork.Custom.tbn") {
|
||||
t.Fatal("NFO-referenced cloud poster should be cached locally during scan")
|
||||
}
|
||||
if got := rec.Body.String(); got != "custom-poster" {
|
||||
t.Fatalf("cached poster body = %q", got)
|
||||
if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) {
|
||||
t.Fatalf("cached poster body = %x", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,7 +211,7 @@ func TestScanCloudLibraryReadsRemoteNFOAndArtwork(t *testing.T) {
|
||||
_, _ = w.Write([]byte(`<episodedetails><showtitle>剑来</showtitle><title>第一集</title><season>1</season><episode>1</episode></episodedetails>`))
|
||||
case "/dav/Anime/JianLai/poster.jpg":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("cloud-poster-bytes"))
|
||||
_, _ = w.Write(testJPEG)
|
||||
default:
|
||||
t.Fatalf("unexpected get path %s", r.URL.Path)
|
||||
}
|
||||
@@ -267,8 +268,8 @@ func TestScanCloudLibraryReadsRemoteNFOAndArtwork(t *testing.T) {
|
||||
if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Anime/JianLai/poster.jpg") {
|
||||
t.Fatal("cloud poster should be cached locally during scan before media is exposed")
|
||||
}
|
||||
if got := rec.Body.String(); got != "cloud-poster-bytes" {
|
||||
t.Fatalf("cached poster body = %q", got)
|
||||
if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) {
|
||||
t.Fatalf("cached poster body = %x", got)
|
||||
}
|
||||
if media.ScrapeStatus != "matched" {
|
||||
t.Fatalf("scrape status = %q", media.ScrapeStatus)
|
||||
@@ -308,7 +309,7 @@ func TestScanCloudLibraryRefreshesExistingRemoteNFOAndArtwork(t *testing.T) {
|
||||
_, _ = w.Write([]byte(`<episodedetails><showtitle>剑来</showtitle><title>第一集</title><season>1</season><episode>1</episode></episodedetails>`))
|
||||
case "/dav/Anime/JianLai/poster.jpg":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("cloud-poster-bytes"))
|
||||
_, _ = w.Write(testJPEG)
|
||||
default:
|
||||
t.Fatalf("unexpected get path %s", r.URL.Path)
|
||||
}
|
||||
@@ -379,7 +380,7 @@ func TestScanCloudLibraryRefreshesExistingRemoteNFOAndArtwork(t *testing.T) {
|
||||
if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Anime/JianLai/poster.jpg") {
|
||||
t.Fatal("refreshed cloud poster should be cached locally during scan")
|
||||
}
|
||||
if got := rec.Body.String(); got != "cloud-poster-bytes" {
|
||||
t.Fatalf("cached poster body = %q", got)
|
||||
if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) {
|
||||
t.Fatalf("cached poster body = %x", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -111,7 +112,7 @@ func TestScanCloudLibraryReadsMovieDirectoryNFOAndCleanTitleArtwork(t *testing.T
|
||||
_, _ = w.Write([]byte(`<movie><title>Action Movie</title><year>2025</year><uniqueid type="tmdb">1197306</uniqueid></movie>`))
|
||||
case "/dav/Movies/Action Movie (2025) {tmdb-1197306}/action movie (2025)-poster.jpg":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("clean-title-poster"))
|
||||
_, _ = w.Write(testJPEG)
|
||||
default:
|
||||
t.Fatalf("unexpected get path %s", r.URL.Path)
|
||||
}
|
||||
@@ -164,8 +165,8 @@ func TestScanCloudLibraryReadsMovieDirectoryNFOAndCleanTitleArtwork(t *testing.T
|
||||
if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Action Movie (2025) {tmdb-1197306}/action movie (2025)-poster.jpg") {
|
||||
t.Fatal("clean-title cloud poster should be cached locally during scan")
|
||||
}
|
||||
if got := rec.Body.String(); got != "clean-title-poster" {
|
||||
t.Fatalf("cached poster body = %q", got)
|
||||
if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) {
|
||||
t.Fatalf("cached poster body = %x", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,10 +202,10 @@ func TestScanCloudLibraryReadsRemoteJSONMetadataAndArtwork(t *testing.T) {
|
||||
_, _ = w.Write([]byte(`{"title":"JSON Sidecar Movie","year":2026,"tmdb_id":12345,"overview":"metadata from cloud json","poster":"poster.jpg","backdrop":"backdrop.jpg","genres":["Action","Drama"]}`))
|
||||
case "/dav/Movies/Sidecar Movie (2026) {tmdb-12345}/poster.jpg":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("json-poster"))
|
||||
_, _ = w.Write(testJPEG)
|
||||
case "/dav/Movies/Sidecar Movie (2026) {tmdb-12345}/backdrop.jpg":
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("json-backdrop"))
|
||||
_, _ = w.Write(testJPEG)
|
||||
default:
|
||||
t.Fatalf("unexpected get path %s", r.URL.Path)
|
||||
}
|
||||
@@ -257,8 +258,8 @@ func TestScanCloudLibraryReadsRemoteJSONMetadataAndArtwork(t *testing.T) {
|
||||
if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/Sidecar Movie (2026) {tmdb-12345}/poster.jpg") {
|
||||
t.Fatal("JSON cloud poster should be cached locally during scan")
|
||||
}
|
||||
if got := rec.Body.String(); got != "json-poster" {
|
||||
t.Fatalf("cached poster body = %q", got)
|
||||
if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) {
|
||||
t.Fatalf("cached poster body = %x", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,7 +399,7 @@ func TestScanCloudLibraryKeepsCloudArtworkWhenEnrichingPathHint(t *testing.T) {
|
||||
t.Fatalf("unexpected get path %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write([]byte("local-cloud-poster"))
|
||||
_, _ = w.Write(testJPEG)
|
||||
default:
|
||||
t.Fatalf("unexpected method %s", r.Method)
|
||||
}
|
||||
@@ -453,7 +454,7 @@ func TestScanCloudLibraryKeepsCloudArtworkWhenEnrichingPathHint(t *testing.T) {
|
||||
if !imageProxy.ServeCloudCached(rec, httptest.NewRequest(http.MethodGet, media.PosterURL, nil), "openlist:/Movies/速度与激情11 (2028) {tmdb-755679}/poster.jpg") {
|
||||
t.Fatal("local cloud poster should be cached during enriched scan")
|
||||
}
|
||||
if got := rec.Body.String(); got != "local-cloud-poster" {
|
||||
t.Fatalf("cached poster body = %q", got)
|
||||
if got := rec.Body.Bytes(); !bytes.Equal(got, testJPEG) {
|
||||
t.Fatalf("cached poster body = %x", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -150,12 +151,11 @@ func TestApplyProviderMatchReplacesArtworkAndRemovesOldCache(t *testing.T) {
|
||||
|
||||
images := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop())
|
||||
images.client = &http.Client{Transport: imageRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
body := "image:" + req.URL.Path
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"image/jpeg"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Body: io.NopCloser(bytes.NewReader(testJPEG)),
|
||||
Request: req,
|
||||
}, nil
|
||||
})}
|
||||
|
||||
Vendored
+43
-4
@@ -1,4 +1,6 @@
|
||||
const ARTWORK_CACHE = 'mediastationgo-artwork-v1'
|
||||
const ARTWORK_CACHE_PREFIX = 'mediastationgo-artwork-'
|
||||
const ARTWORK_CACHE = `${ARTWORK_CACHE_PREFIX}v2`
|
||||
const MIN_CACHEABLE_ARTWORK_BYTES = 128
|
||||
const STRIP_QUERY_KEYS = ['token', 'profile_id', 'profile_pin_token']
|
||||
|
||||
function isArtworkRequest(url) {
|
||||
@@ -33,6 +35,14 @@ self.addEventListener('fetch', (event) => {
|
||||
event.respondWith(cacheArtwork(request))
|
||||
})
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(self.skipWaiting())
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(deleteOldArtworkCaches().then(() => self.clients.claim()))
|
||||
})
|
||||
|
||||
async function cacheArtwork(request) {
|
||||
const cache = await caches.open(ARTWORK_CACHE)
|
||||
const cacheKey = normalizedArtworkRequest(request)
|
||||
@@ -40,14 +50,43 @@ async function cacheArtwork(request) {
|
||||
if (cached) return cached
|
||||
|
||||
const response = await fetch(request)
|
||||
const contentType = response.headers.get('Content-Type') || ''
|
||||
if (response.ok && contentType.toLowerCase().startsWith('image/')) {
|
||||
await cache.put(cacheKey, response.clone())
|
||||
const cacheResponse = await cloneCacheableArtworkResponse(response)
|
||||
if (cacheResponse) {
|
||||
await cache.put(cacheKey, cacheResponse)
|
||||
await deleteOldArtworkVariants(cache, cacheKey)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
async function cloneCacheableArtworkResponse(response) {
|
||||
if (!response.ok) return null
|
||||
const contentType = response.headers.get('Content-Type') || ''
|
||||
if (!contentType.toLowerCase().startsWith('image/')) return null
|
||||
const cacheControl = response.headers.get('Cache-Control') || ''
|
||||
if (/\bno-store\b/i.test(cacheControl)) return null
|
||||
|
||||
const contentLength = Number(response.headers.get('Content-Length') || '0')
|
||||
if (Number.isFinite(contentLength) && contentLength > 0 && contentLength <= MIN_CACHEABLE_ARTWORK_BYTES) {
|
||||
return null
|
||||
}
|
||||
|
||||
const buffer = await response.clone().arrayBuffer()
|
||||
if (buffer.byteLength <= MIN_CACHEABLE_ARTWORK_BYTES) return null
|
||||
return new Response(buffer, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: new Headers(response.headers),
|
||||
})
|
||||
}
|
||||
|
||||
async function deleteOldArtworkCaches() {
|
||||
const names = await caches.keys()
|
||||
await Promise.all(names.map((name) => {
|
||||
if (!name.startsWith(ARTWORK_CACHE_PREFIX) || name === ARTWORK_CACHE) return undefined
|
||||
return caches.delete(name)
|
||||
}))
|
||||
}
|
||||
|
||||
async function deleteOldArtworkVariants(cache, currentRequest) {
|
||||
const currentURL = new URL(currentRequest.url)
|
||||
const currentIdentity = artworkIdentity(currentURL)
|
||||
|
||||
+12
-2
@@ -151,11 +151,21 @@ export function hlsURL(mediaId: string): string {
|
||||
|
||||
// imageURL converts a remote poster URL into a same-origin proxy URL so it
|
||||
// can never be blocked by CORS / GFW. Empty strings pass through unchanged.
|
||||
export function imageURL(remote?: string, version?: string, retryFailed = false): string {
|
||||
export type ImageURLOptions =
|
||||
| boolean
|
||||
| {
|
||||
refreshCache?: boolean
|
||||
retryFailed?: boolean
|
||||
}
|
||||
|
||||
export function imageURL(remote?: string, version?: string, options: ImageURLOptions = false): string {
|
||||
if (!remote) return ''
|
||||
const versionQuery = version ? `v=${encodeURIComponent(version)}` : ''
|
||||
const retryFailed = typeof options === 'boolean' ? options : Boolean(options.retryFailed)
|
||||
const refreshCache = typeof options === 'boolean' ? false : Boolean(options.refreshCache)
|
||||
const retryQuery = retryFailed ? 'retry=1' : ''
|
||||
const imageQuery = [versionQuery, retryQuery].filter(Boolean).join('&')
|
||||
const refreshQuery = refreshCache ? 'refresh=1' : ''
|
||||
const imageQuery = [versionQuery, retryQuery, refreshQuery].filter(Boolean).join('&')
|
||||
if (remote.startsWith('/api/img')) return withQuery(withoutAuthQuery(remote), imageQuery)
|
||||
if (remote.startsWith('/api/cloud/play/')) return withQuery(withoutAuthQuery(remote), imageQuery)
|
||||
if (remote.startsWith('/api/')) return withQuery(withQuery(remote, tokenQuery()), imageQuery)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ImageOff, Info } from 'lucide-react'
|
||||
import { Info } from 'lucide-react'
|
||||
|
||||
import type { DiscoverItem } from '../api/discover'
|
||||
import { imageURL } from '../api/client'
|
||||
@@ -9,11 +9,13 @@ export function ContentRow({
|
||||
title,
|
||||
items,
|
||||
imageVersion,
|
||||
refreshImageVersion,
|
||||
onSelect,
|
||||
}: {
|
||||
title: string
|
||||
items: DiscoverItem[]
|
||||
imageVersion?: string
|
||||
refreshImageVersion?: string
|
||||
onSelect: (item: DiscoverItem) => void
|
||||
}) {
|
||||
return (
|
||||
@@ -21,7 +23,13 @@ export function ContentRow({
|
||||
<h2 className="pl-1 font-display text-2xl font-semibold text-ink-600">{title}</h2>
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7 xl:grid-cols-8">
|
||||
{items.map((item, index) => (
|
||||
<DiscoverCard key={discoverKey(item, index)} item={item} imageVersion={imageVersion} onSelect={onSelect} />
|
||||
<DiscoverCard
|
||||
key={discoverKey(item, index)}
|
||||
item={item}
|
||||
imageVersion={imageVersion}
|
||||
refreshImageVersion={refreshImageVersion}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
@@ -48,24 +56,47 @@ export function DiscoverSkeleton() {
|
||||
function DiscoverCard({
|
||||
item,
|
||||
imageVersion,
|
||||
refreshImageVersion,
|
||||
onSelect,
|
||||
}: {
|
||||
item: DiscoverItem
|
||||
imageVersion?: string
|
||||
refreshImageVersion?: string
|
||||
onSelect: (item: DiscoverItem) => void
|
||||
}) {
|
||||
const source = discoverItemSource(item)
|
||||
const [posterFailed, setPosterFailed] = useState(false)
|
||||
const [posterRetry, setPosterRetry] = useState(0)
|
||||
const [posterUnavailable, setPosterUnavailable] = useState(false)
|
||||
const posterVersion = [imageVersion, posterRetry > 0 ? `r${posterRetry}` : ''].filter(Boolean).join('-')
|
||||
const shouldRefreshCache = Boolean(
|
||||
(imageVersion && refreshImageVersion === imageVersion) || posterRetry > 0,
|
||||
)
|
||||
const posterSrc = useMemo(
|
||||
() => imageURL(item.poster_url, imageVersion, true),
|
||||
[imageVersion, item.poster_url],
|
||||
() =>
|
||||
imageURL(item.poster_url, posterVersion, {
|
||||
refreshCache: shouldRefreshCache,
|
||||
retryFailed: true,
|
||||
}),
|
||||
[item.poster_url, posterVersion, shouldRefreshCache],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setPosterFailed(false)
|
||||
}, [posterSrc])
|
||||
setPosterRetry(0)
|
||||
setPosterUnavailable(false)
|
||||
}, [item.poster_url])
|
||||
|
||||
const showFallback = !posterSrc || posterFailed
|
||||
useEffect(() => {
|
||||
if (!posterUnavailable || posterRetry >= 3) return
|
||||
const timer = window.setTimeout(() => {
|
||||
setPosterRetry((current) => current + 1)
|
||||
setPosterUnavailable(false)
|
||||
}, 1200 * (posterRetry + 1))
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [posterRetry, posterUnavailable])
|
||||
|
||||
const markPosterUnavailable = () => setPosterUnavailable(true)
|
||||
|
||||
if (!posterSrc || posterUnavailable) return null
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -80,24 +111,16 @@ function DiscoverCard({
|
||||
alt={item.title}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setPosterFailed(true)}
|
||||
onError={markPosterUnavailable}
|
||||
onLoad={(event) => {
|
||||
const img = event.currentTarget
|
||||
setPosterFailed(img.naturalWidth <= 1 && img.naturalHeight <= 1)
|
||||
if (img.naturalWidth <= 1 && img.naturalHeight <= 1) {
|
||||
markPosterUnavailable()
|
||||
}
|
||||
}}
|
||||
className={
|
||||
'h-full w-full object-cover transition-transform duration-500 group-hover:scale-105 ' +
|
||||
(posterFailed ? 'opacity-0' : 'opacity-100')
|
||||
}
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
)}
|
||||
{showFallback && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-gray-100 px-3 text-center text-gray-500">
|
||||
<ImageOff size={22} className="text-gray-400" />
|
||||
<span className="line-clamp-2 text-xs font-medium text-gray-600">{item.title}</span>
|
||||
<span className="text-[10px] text-gray-400">{posterSrc ? '海报待刷新' : '无海报'}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-1.5 top-1.5 rounded-xl border border-white/20 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-white backdrop-blur-sm">
|
||||
{source}
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ export function DiscoverPage() {
|
||||
const [activeItem, setActiveItem] = useState<DiscoverItem | null>(null)
|
||||
const [reloadSeq, setReloadSeq] = useState(0)
|
||||
const [imageVersion, setImageVersion] = useState(() => String(Date.now()))
|
||||
const [refreshImageVersion, setRefreshImageVersion] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -122,7 +123,9 @@ export function DiscoverPage() {
|
||||
}
|
||||
|
||||
const refreshDiscover = () => {
|
||||
setImageVersion(String(Date.now()))
|
||||
const nextImageVersion = String(Date.now())
|
||||
setImageVersion(nextImageVersion)
|
||||
setRefreshImageVersion(nextImageVersion)
|
||||
setReloadSeq((current) => current + 1)
|
||||
}
|
||||
|
||||
@@ -200,6 +203,7 @@ export function DiscoverPage() {
|
||||
title={sectionMap.get(key)?.label ?? key}
|
||||
items={items}
|
||||
imageVersion={imageVersion}
|
||||
refreshImageVersion={refreshImageVersion}
|
||||
onSelect={setActiveItem}
|
||||
/>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user