diff --git a/internal/handler/cloud_test.go b/internal/handler/cloud_test.go
index b60b1db..c749116 100644
--- a/internal/handler/cloud_test.go
+++ b/internal/handler/cloud_test.go
@@ -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)
}
}
diff --git a/internal/handler/emby_items_test.go b/internal/handler/emby_items_test.go
index fcb4b0b..94afd63 100644
--- a/internal/handler/emby_items_test.go
+++ b/internal/handler/emby_items_test.go
@@ -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 != "" {
diff --git a/internal/handler/streaming.go b/internal/handler/streaming.go
index 1069d5c..edcd038 100644
--- a/internal/handler/streaming.go
+++ b/internal/handler/streaming.go
@@ -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
diff --git a/internal/service/discover_artwork_test.go b/internal/service/discover_artwork_test.go
index e0ca7d5..170805a 100644
--- a/internal/service/discover_artwork_test.go
+++ b/internal/service/discover_artwork_test.go
@@ -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
})}
diff --git a/internal/service/image_proxy.go b/internal/service/image_proxy.go
index 3e815d8..e0f9080 100644
--- a/internal/service/image_proxy.go
+++ b/internal/service/image_proxy.go
@@ -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
)
diff --git a/internal/service/image_proxy_cache.go b/internal/service/image_proxy_cache.go
index 468aa22..691d826 100644
--- a/internal/service/image_proxy_cache.go
+++ b/internal/service/image_proxy_cache.go
@@ -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)
diff --git a/internal/service/image_proxy_cloud.go b/internal/service/image_proxy_cloud.go
index a04f070..95afffe 100644
--- a/internal/service/image_proxy_cloud.go
+++ b/internal/service/image_proxy_cloud.go
@@ -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
}
diff --git a/internal/service/image_proxy_remote.go b/internal/service/image_proxy_remote.go
index 2a6aaa0..7f67e5f 100644
--- a/internal/service/image_proxy_remote.go
+++ b/internal/service/image_proxy_remote.go
@@ -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
}
diff --git a/internal/service/image_proxy_test.go b/internal/service/image_proxy_test.go
index 19f1bad..e7ec17b 100644
--- a/internal/service/image_proxy_test.go
+++ b/internal/service/image_proxy_test.go
@@ -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("not image")),
+ 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("not a poster")),
+ 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("old bad cache"), 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)
}
}
diff --git a/internal/service/proxy.go b/internal/service/proxy.go
index 4410b56..15e45f9 100644
--- a/internal/service/proxy.go
+++ b/internal/service/proxy.go
@@ -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,
diff --git a/internal/service/scanner_cloud_artwork_test.go b/internal/service/scanner_cloud_artwork_test.go
index 288ad64..5d8ee13 100644
--- a/internal/service/scanner_cloud_artwork_test.go
+++ b/internal/service/scanner_cloud_artwork_test.go
@@ -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(`