From aa506d656655454e996f19832ba9299a2f6ad909 Mon Sep 17 00:00:00 2001 From: ShukeBta <272197458+ShukeBta@users.noreply.github.com> Date: Wed, 24 Jun 2026 19:26:32 +0800 Subject: [PATCH] fix: harden discover artwork caching --- internal/handler/cloud_test.go | 11 +- internal/handler/emby_items_test.go | 5 +- internal/handler/streaming.go | 4 +- internal/service/discover_artwork_test.go | 4 +- internal/service/image_proxy.go | 2 +- internal/service/image_proxy_cache.go | 24 +- internal/service/image_proxy_cloud.go | 19 +- internal/service/image_proxy_remote.go | 203 ++++++++++-- internal/service/image_proxy_test.go | 303 +++++++++++++++++- internal/service/proxy.go | 1 + .../service/scanner_cloud_artwork_test.go | 29 +- .../service/scanner_cloud_metadata_test.go | 21 +- internal/service/scraper_artwork_test.go | 4 +- web/public/artwork-cache-sw.js | 47 ++- web/src/api/client.ts | 14 +- web/src/pages/DiscoverContentRow.tsx | 65 ++-- web/src/pages/DiscoverPage.tsx | 6 +- 17 files changed, 658 insertions(+), 104 deletions(-) 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(`Sidecar Movie2026`)) 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(`NFO Custom ArtworkArtwork.Custom.tbnScene.Still.png?version=1`)) 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(`剑来第一集11`)) 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(`剑来第一集11`)) 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) } } diff --git a/internal/service/scanner_cloud_metadata_test.go b/internal/service/scanner_cloud_metadata_test.go index 7364936..ba14f71 100644 --- a/internal/service/scanner_cloud_metadata_test.go +++ b/internal/service/scanner_cloud_metadata_test.go @@ -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(`Action Movie20251197306`)) 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) } } diff --git a/internal/service/scraper_artwork_test.go b/internal/service/scraper_artwork_test.go index de65d48..9c536ef 100644 --- a/internal/service/scraper_artwork_test.go +++ b/internal/service/scraper_artwork_test.go @@ -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 })} diff --git a/web/public/artwork-cache-sw.js b/web/public/artwork-cache-sw.js index c1dbafc..e299e83 100644 --- a/web/public/artwork-cache-sw.js +++ b/web/public/artwork-cache-sw.js @@ -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) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 666dec3..b1e8714 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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) diff --git a/web/src/pages/DiscoverContentRow.tsx b/web/src/pages/DiscoverContentRow.tsx index 56d4e21..f902bcf 100644 --- a/web/src/pages/DiscoverContentRow.tsx +++ b/web/src/pages/DiscoverContentRow.tsx @@ -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({

{title}

{items.map((item, index) => ( - + ))}
@@ -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 (