diff --git a/internal/handler/streaming.go b/internal/handler/streaming.go index 7073824..1069d5c 100644 --- a/internal/handler/streaming.go +++ b/internal/handler/streaming.go @@ -70,6 +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") != "" { + _ = svc.ImageProxy.RemoveFailed(raw) + } // Serve handles upstream errors internally by returning a 1×1 PNG // placeholder, so the only error we can get back here is a malformed // URL. In that case we still return 400 to make the misuse visible. diff --git a/internal/service/image_proxy_remote.go b/internal/service/image_proxy_remote.go index 1add929..2a6aaa0 100644 --- a/internal/service/image_proxy_remote.go +++ b/internal/service/image_proxy_remote.go @@ -38,6 +38,20 @@ func (p *ImageProxy) RemoveCached(raw string) error { return nil } +func (p *ImageProxy) RemoveFailed(raw string) error { + if !isHTTPish(raw) { + return nil + } + _, _, failPath, err := p.remoteImageCachePaths(raw) + if err != nil { + return nil + } + if err := os.Remove(failPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return nil +} + // Serve writes the requested image to w. Caller is expected to validate // the JWT before invoking it. func (p *ImageProxy) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request, raw string) error { diff --git a/internal/service/image_proxy_test.go b/internal/service/image_proxy_test.go index 0234594..19f1bad 100644 --- a/internal/service/image_proxy_test.go +++ b/internal/service/image_proxy_test.go @@ -132,6 +132,52 @@ func TestImageProxyCachesFailedRemoteImageFetch(t *testing.T) { } } +func TestImageProxyRemoveFailedAllowsRetry(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) { + call := atomic.AddInt32(&calls, 1) + if call == 1 { + return &http.Response{ + StatusCode: http.StatusBadGateway, + Status: "502 Bad Gateway", + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("upstream unavailable")), + Request: req, + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Header: http.Header{"Content-Type": []string{"image/jpeg"}}, + Body: io.NopCloser(strings.NewReader("poster-bytes")), + Request: req, + }, nil + })} + + raw := "https://image.tmdb.org/t/p/w500/retry-poster.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("first body length = %d, want placeholder %d", rec.Body.Len(), len(transparent1x1PNG)) + } + if err := proxy.RemoveFailed(raw); err != nil { + t.Fatal(err) + } + rec = httptest.NewRecorder() + 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 := atomic.LoadInt32(&calls); got != 2 { + t.Fatalf("upstream calls = %d, want 2 after retry", got) + } +} + func TestImageProxyCachesCloudResolvedImage(t *testing.T) { var calls int32 proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop()) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1d50707..666dec3 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -151,13 +151,15 @@ 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): string { +export function imageURL(remote?: string, version?: string, retryFailed = false): string { if (!remote) return '' const versionQuery = version ? `v=${encodeURIComponent(version)}` : '' - if (remote.startsWith('/api/img')) return withQuery(withoutAuthQuery(remote), versionQuery) - if (remote.startsWith('/api/cloud/play/')) return withQuery(withoutAuthQuery(remote), versionQuery) - if (remote.startsWith('/api/')) return withQuery(withQuery(remote, tokenQuery()), versionQuery) - return withQuery(`/api/img?url=${encodeURIComponent(remote)}`, versionQuery) + const retryQuery = retryFailed ? 'retry=1' : '' + const imageQuery = [versionQuery, retryQuery].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) + return withQuery(`/api/img?url=${encodeURIComponent(remote)}`, imageQuery) } function withQuery(url: string, query: string): string { diff --git a/web/src/pages/DiscoverContentRow.tsx b/web/src/pages/DiscoverContentRow.tsx index a4c5560..56d4e21 100644 --- a/web/src/pages/DiscoverContentRow.tsx +++ b/web/src/pages/DiscoverContentRow.tsx @@ -1,4 +1,5 @@ -import { Info } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { ImageOff, Info } from 'lucide-react' import type { DiscoverItem } from '../api/discover' import { imageURL } from '../api/client' @@ -7,10 +8,12 @@ import { discoverItemSource } from './discoverPageModel' export function ContentRow({ title, items, + imageVersion, onSelect, }: { title: string items: DiscoverItem[] + imageVersion?: string onSelect: (item: DiscoverItem) => void }) { return ( @@ -18,7 +21,7 @@ export function ContentRow({