From 8bfdd75d47b07e23ea1b765558e561acc7aea856 Mon Sep 17 00:00:00 2001 From: truewhile <779943132@qq.com> Date: Sun, 4 Oct 2026 22:17:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/service/emby_remote.go | 99 +++++++++++++- .../service/emby_remote_image_auth_test.go | 100 ++++++++++++++ internal/service/image_proxy.go | 11 ++ internal/service/image_proxy_remote.go | 51 +++++-- internal/service/image_proxy_remote_auth.go | 102 ++++++++++++++ .../service/image_proxy_remote_auth_test.go | 126 ++++++++++++++++++ internal/service/image_proxy_remote_fetch.go | 5 + internal/service/service_builder.go | 9 ++ 8 files changed, 489 insertions(+), 14 deletions(-) create mode 100644 internal/service/emby_remote_image_auth_test.go create mode 100644 internal/service/image_proxy_remote_auth.go create mode 100644 internal/service/image_proxy_remote_auth_test.go diff --git a/internal/service/emby_remote.go b/internal/service/emby_remote.go index 19ec3b2..d99a087 100644 --- a/internal/service/emby_remote.go +++ b/internal/service/emby_remote.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/url" "regexp" @@ -237,6 +238,88 @@ func (r *EmbyRemoteService) ConfiguredRemoteHosts(ctx context.Context) []string return hosts } +// findAccountByHost 返回某条线路主机名匹配 host 的启用远程 Emby 账号(无则 nil)。 +func (r *EmbyRemoteService) findAccountByHost(ctx context.Context, host string) *model.StrmAccount { + host = normalizeHostKey(host) + if host == "" || r == nil || r.repo == nil || r.repo.StrmAccount == nil { + return nil + } + accounts, err := r.ListAccounts(ctx) + if err != nil { + return nil + } + for i := range accounts { + lines, _, err := r.LinesOf(&accounts[i]) + if err != nil { + continue + } + for _, line := range lines { + u, err := url.Parse(line.URL) + if err != nil { + continue + } + if normalizeHostKey(u.Hostname()) == host { + return &accounts[i] + } + } + } + return nil +} + +// normalizeHostKey 去掉端口并小写化,便于主机名字符串比较。 +func normalizeHostKey(host string) string { + host = strings.ToLower(strings.TrimSpace(host)) + if host == "" { + return "" + } + if h, _, err := net.SplitHostPort(host); err == nil { + return strings.ToLower(strings.TrimSpace(h)) + } + return host +} + +// RemoteEmbyImageTokenForHost 返回图片代理回源某个远程 Emby 主机所需的当前 +// api_key。host 不属于任何已配置账号时返回 ok=false。 +func (r *EmbyRemoteService) RemoteEmbyImageTokenForHost(ctx context.Context, host string) (string, bool) { + if r == nil || r.repo == nil { + return "", false + } + acct := r.findAccountByHost(ctx, host) + if acct == nil { + return "", false + } + cfg, err := r.configOf(acct) + if err != nil || strings.TrimSpace(cfg.Token) == "" { + return "", false + } + return cfg.Token, true +} + +// RefreshRemoteEmbyImageTokenForHost 强制用用户名/密码重新登录拥有该主机的账号 +// 并持久化新 token,供图片代理在上游 401 时自愈。仅配置了 api_key、没有登录 +// 凭据的账号无法自动刷新,返回错误由调用方按原失败处理。 +func (r *EmbyRemoteService) RefreshRemoteEmbyImageTokenForHost(ctx context.Context, host string) (string, error) { + if r == nil || r.repo == nil { + return "", errors.New("远程 Emby 服务不可用") + } + acct := r.findAccountByHost(ctx, host) + if acct == nil { + return "", fmt.Errorf("未找到主机 %s 对应的远程 Emby 账号", host) + } + cfg, err := r.configOf(acct) + if err != nil { + return "", err + } + if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { + return "", errors.New("远程 Emby 账号未配置用户名/密码,无法自动刷新 api_key") + } + cfg.Token = "" // 强制走登录流程,忽略已失效的 api_key + if err := r.ensureToken(ctx, acct, cfg); err != nil { + return "", err + } + return cfg.Token, nil +} + // AccountByID 按 ID 查找远程 Emby 挂载账号(不存在或类型不符返回 nil)。 func (r *EmbyRemoteService) AccountByID(ctx context.Context, id string) *model.StrmAccount { if strings.TrimSpace(id) == "" { @@ -699,17 +782,23 @@ func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAcc return err } if status == http.StatusUnauthorized && attempt == 0 { - // 401:只清当前线路的内存 token 并立即重认证;不在此时删除 - // DB 里的 api_key——①外层还会按线路故障转移(其他线路可能 - // 存有自己的 token);②纯 api_key 账号删除后无法再认证,一次 - // 线路误报就会把账号“砖化”。重认证成功后 persistToken 会用 - // 新 token 覆盖 api_key。 + // 401:只清当前线路的内存 token 并立即重认证;不先删 DB 里的 + // api_key——纯 api_key 账号删除后无法再认证,一次线路误报就会 + // 把账号“砖化”。重认证成功后把新 token 写回:既让重试用上正确 + // 凭据,也避免每个后续请求都重新登录一次。 + previous := cfg.Token cfg.Token = "" if err := r.ensureTokenOnLine(ctx, acct, cfg); err != nil { return fmt.Errorf("认证重试失败: %w", err) } + if q != nil { + q.Set("api_key", cfg.Token) + } master.Token = cfg.Token master.RemoteUserID = cfg.RemoteUserID + if cfg.Token != "" && cfg.Token != previous { + _ = r.persistToken(ctx, acct, cfg) + } continue } if status >= 300 { diff --git a/internal/service/emby_remote_image_auth_test.go b/internal/service/emby_remote_image_auth_test.go new file mode 100644 index 0000000..6b1d3d4 --- /dev/null +++ b/internal/service/emby_remote_image_auth_test.go @@ -0,0 +1,100 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" +) + +func TestRemoteEmbyImageTokenForHostUnknownHost(t *testing.T) { + svc, _, _ := newImageTagTestService(t) + + if token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), "nope.example"); ok || token != "" { + t.Fatalf("got (%q,%v), want no token for a host not owned by any account", token, ok) + } +} + +func TestRemoteEmbyImageTokenForHostMatchesConfiguredLine(t *testing.T) { + svc, _, _ := newImageTagTestService(t) + + token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), "emby.test") + if !ok || token != "fake-token" { + t.Fatalf("got (%q,%v), want the account token for the configured line host", token, ok) + } +} + +func TestRefreshRemoteEmbyImageTokenForHostRequiresCredentials(t *testing.T) { + svc, _, _ := newImageTagTestService(t) + + // The account carries only an api_key — there is nothing to re-authenticate with. + if _, err := svc.RefreshRemoteEmbyImageTokenForHost(t.Context(), "emby.test"); err == nil { + t.Fatal("expected an error when the account has no username/password to re-authenticate with") + } +} + +// TestRefreshRemoteEmbyImageTokenForHostRelogins 覆盖「token 被撤销后自动恢复」: +// 用用户名/密码重新登录拿到新 token,并持久化,后续请求不再重复登录。 +func TestRefreshRemoteEmbyImageTokenForHostRelogins(t *testing.T) { + const fresh = "fresh-token" + logins := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/emby/Users/AuthenticateByName" { + http.NotFound(w, r) + return + } + logins++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"AccessToken":"` + fresh + `","User":{"Id":"remote-user"}}`)) + })) + defer upstream.Close() + + db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}) + repos := repository.New(db) + svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop())) + acct := &model.StrmAccount{ + Base: model.Base{ID: "acct-refresh"}, + Name: "refresh-emby", + Provider: model.StrmProviderEmbyRemote, + Enabled: true, + Config: `{"url":"` + upstream.URL + `","api_key":"stale-token","username":"u","password":"p"}`, + } + if err := repos.StrmAccount.Create(t.Context(), acct); err != nil { + t.Fatalf("create account: %v", err) + } + + u, err := url.Parse(upstream.URL) + if err != nil { + t.Fatal(err) + } + host := u.Hostname() + + if token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), host); !ok || token != "stale-token" { + t.Fatalf("token before refresh = (%q,%v), want the stale stored token", token, ok) + } + + token, err := svc.RefreshRemoteEmbyImageTokenForHost(t.Context(), host) + if err != nil { + t.Fatalf("refresh: %v", err) + } + if token != fresh { + t.Fatalf("refreshed token = %q, want %q", token, fresh) + } + if logins != 1 { + t.Fatalf("logins = %d, want 1", logins) + } + + // 新 token 必须落库,否则每个请求都要重新登录一次。 + if token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), host); !ok || token != fresh { + t.Fatalf("token after refresh = (%q,%v), want the persisted %q", token, ok, fresh) + } + if logins != 1 { + t.Fatalf("logins = %d, want 1 after the refresh was persisted", logins) + } +} diff --git a/internal/service/image_proxy.go b/internal/service/image_proxy.go index 91a7a52..4f04cb1 100644 --- a/internal/service/image_proxy.go +++ b/internal/service/image_proxy.go @@ -13,6 +13,7 @@ package service import ( + "context" "errors" "net" "net/http" @@ -65,6 +66,16 @@ type ImageProxy struct { allowedHostsMu sync.Mutex allowedHostsCache map[string]bool allowedHostsAt time.Time + + // remoteEmbyTokenFn / refreshRemoteEmbyTokenFn let the proxy carry a fresh + // credential for configured remote-Emby mounts. Image URLs are minted from + // whatever token the account had when the payload was built, so a URL can + // outlive its api_key; these hooks re-supply the current one and force a + // re-login when the mount rejects it. + remoteEmbyTokenFn func(ctx context.Context, host string) (string, bool) + refreshRemoteEmbyTokenFn func(ctx context.Context, host string) (string, error) + remoteEmbyTokenMu sync.Mutex + remoteEmbyTokenCache map[string]remoteEmbyImageToken } const ( diff --git a/internal/service/image_proxy_remote.go b/internal/service/image_proxy_remote.go index 69468ee..4920cec 100644 --- a/internal/service/image_proxy_remote.go +++ b/internal/service/image_proxy_remote.go @@ -14,6 +14,10 @@ import ( var errImageProxyRequestSetup = errors.New("image proxy request setup failed") var errImageProxyNonImageContent = errors.New("upstream returned non-image content") +// errImageProxyUnauthorized 表示上游以 401/403 拒绝了回源凭据。它与普通失败 +// 分开,是为了让调用方对挂载 Emby 做一次重认证再重试,而不是直接下发占位图。 +var errImageProxyUnauthorized = errors.New("upstream rejected image credentials") + // prefetchCardResizeOptions 对应前端 ARTWORK.posterCard(见 web/src/api/client.ts): // 卡片是海报墙最常请求的档位,刮削阶段预生成它能让首个列表请求直接命中缓存。 // 若前端调整该预设,这里只是白生成一份用不到的档位(约几十 KB),不影响正确性。 @@ -183,16 +187,27 @@ func (p *ImageProxy) removeUnusableImageCache(cachePath, failPath string) { } func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, cachePath, failPath string) (remoteImageFetchResult, error) { - var lastErr error - for _, candidate := range p.remoteImageFetchClients(host) { - result, err := p.fetchRemoteImageOnce(ctx, raw, host, candidate, cachePath, failPath) - if err == nil { - return result, nil + result, lastErr := p.fetchRemoteImageAcrossClients(ctx, raw, host, cachePath, failPath) + if lastErr == nil { + return result, nil + } + if errors.Is(lastErr, errImageProxyRequestSetup) { + return remoteImageFetchResult{}, lastErr + } + // 已挂载的远程 Emby 认 X-Emby-Token 请求头,但轮换前生成的图片 URL 里还留着 + // 旧 api_key。上游拒绝时重新登录拥有该主机的账号一次再重试,而不是一直下发 + // 占位图、等人工去改账号配置。 + if errors.Is(lastErr, errImageProxyUnauthorized) { + token, refreshErr := p.refreshRemoteEmbyTokenForHost(ctx, host) + if refreshErr != nil { + logImageFetchError(p.log, "imageproxy: remote emby re-auth failed", host, "reauth", refreshErr) + } else if token != "" { + retried, retryErr := p.fetchRemoteImageAcrossClients(ctx, raw, host, cachePath, failPath) + if retryErr == nil { + return retried, nil + } + lastErr = retryErr } - if errors.Is(err, errImageProxyRequestSetup) { - return remoteImageFetchResult{}, err - } - lastErr = err } if p.canUseExternalImageFallback() && isDoubanImageHost(host) { data, ctype, _, err := fetchRemoteImageWithCurl(ctx, raw, host) @@ -210,6 +225,24 @@ func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, ca return remoteImageFetchResult{}, redactSensitiveError(lastErr) } +// fetchRemoteImageAcrossClients 按直连/代理顺序依次尝试,返回首个成功结果。 +// 请求构造失败立即中止(重试无意义);401/403 会继续尝试另一个客户端,并把 +// errImageProxyUnauthorized 作为最终错误交给调用方去刷新挂载 token。 +func (p *ImageProxy) fetchRemoteImageAcrossClients(ctx context.Context, raw, host, cachePath, failPath string) (remoteImageFetchResult, error) { + var lastErr error + for _, candidate := range p.remoteImageFetchClients(host) { + result, err := p.fetchRemoteImageOnce(ctx, raw, host, candidate, cachePath, failPath) + if err == nil { + return result, nil + } + if errors.Is(err, errImageProxyRequestSetup) { + return remoteImageFetchResult{}, err + } + lastErr = err + } + return remoteImageFetchResult{}, lastErr +} + // fetchAndCacheRemoteImageShared coalesces concurrent requests for the same // upstream image. A poster can appear in the hero, a shelf and the detail page // at the same time; without this guard every resize variant may fetch the same diff --git a/internal/service/image_proxy_remote_auth.go b/internal/service/image_proxy_remote_auth.go new file mode 100644 index 0000000..add05e9 --- /dev/null +++ b/internal/service/image_proxy_remote_auth.go @@ -0,0 +1,102 @@ +package service + +import ( + "context" + "net/http" + "strings" + "time" +) + +// remoteEmbyImageTokenTTL 是 host → api_key 的缓存时长。海报墙首屏一口气请求 +// 几十张图,逐个回读数据库不值得;token 轮换时 401 重认证会立即覆盖这份缓存。 +const remoteEmbyImageTokenTTL = 60 * time.Second + +type remoteEmbyImageToken struct { + token string + at time.Time +} + +// SetRemoteEmbyAuthProvider 注入「按主机名取当前远程 Emby api_key」的回调。 +// 图片代理回源已挂载的远程 Emby 时用它替换 URL 里可能已过期的凭据。 +func (p *ImageProxy) SetRemoteEmbyAuthProvider(fn func(ctx context.Context, host string) (string, bool)) { + if p == nil { + return + } + p.remoteEmbyTokenFn = fn +} + +// SetRemoteEmbyAuthRefresher 注入「强制重新登录并返回新 api_key」的回调。上游以 +// 401/403 拒绝旧 token 时调用一次,再重试拉图,从而不需要人工改账号配置。 +func (p *ImageProxy) SetRemoteEmbyAuthRefresher(fn func(ctx context.Context, host string) (string, error)) { + if p == nil { + return + } + p.refreshRemoteEmbyTokenFn = fn +} + +// remoteEmbyTokenForHost 返回挂载 Emby 的当前 token,短期缓存避免每张图都查库。 +// 「不是挂载主机」的结果同样缓存,否则普通图床(TMDb/Douban 等)的每张海报都会 +// 白白查一次账号表。 +func (p *ImageProxy) remoteEmbyTokenForHost(ctx context.Context, host string) string { + if p == nil || p.remoteEmbyTokenFn == nil || strings.TrimSpace(host) == "" { + return "" + } + p.remoteEmbyTokenMu.Lock() + if cached, ok := p.remoteEmbyTokenCache[host]; ok && time.Since(cached.at) < remoteEmbyImageTokenTTL { + p.remoteEmbyTokenMu.Unlock() + return cached.token + } + p.remoteEmbyTokenMu.Unlock() + + token, ok := p.remoteEmbyTokenFn(ctx, host) + if !ok { + token = "" + } + p.cacheRemoteEmbyToken(host, token) + return token +} + +func (p *ImageProxy) cacheRemoteEmbyToken(host, token string) { + if p == nil || strings.TrimSpace(host) == "" { + return + } + p.remoteEmbyTokenMu.Lock() + if p.remoteEmbyTokenCache == nil { + p.remoteEmbyTokenCache = make(map[string]remoteEmbyImageToken, 8) + } + p.remoteEmbyTokenCache[host] = remoteEmbyImageToken{token: token, at: time.Now()} + p.remoteEmbyTokenMu.Unlock() +} + +// refreshRemoteEmbyTokenForHost 强制重认证一次并刷新缓存。没有配置刷新器、或 +// 账号无法自动刷新(如仅填了 api_key)时返回空串,调用方按原错误处理。 +func (p *ImageProxy) refreshRemoteEmbyTokenForHost(ctx context.Context, host string) (string, error) { + if p == nil || p.refreshRemoteEmbyTokenFn == nil || strings.TrimSpace(host) == "" { + return "", nil + } + token, err := p.refreshRemoteEmbyTokenFn(ctx, host) + if err != nil { + return "", err + } + if token != "" { + p.cacheRemoteEmbyToken(host, token) + } + return token, nil +} + +// applyRemoteEmbyAuth 用账号当前 token 覆盖回源请求上的凭据。已配置的远程 Emby +// 以 X-Emby-Token 请求头为准,所以图片 URL 里带的是轮换前的旧 api_key 也不会再 +// 被采纳;同时把查询参数里的 api_key 一并改写,避免旧值干扰。 +func (p *ImageProxy) applyRemoteEmbyAuth(ctx context.Context, req *http.Request, host string) { + if p == nil || p.remoteEmbyTokenFn == nil || req == nil || req.URL == nil { + return + } + token := p.remoteEmbyTokenForHost(ctx, host) + if token == "" { + return + } + req.Header.Set("X-Emby-Token", token) + q := req.URL.Query() + q.Set("api_key", token) + req.URL.RawQuery = q.Encode() +} diff --git a/internal/service/image_proxy_remote_auth_test.go b/internal/service/image_proxy_remote_auth_test.go new file mode 100644 index 0000000..f2ea5a1 --- /dev/null +++ b/internal/service/image_proxy_remote_auth_test.go @@ -0,0 +1,126 @@ +package service + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" +) + +// TestImageProxyInjectsRemoteEmbyToken 覆盖封面空白问题:图片 URL 里签发时的 +// api_key 已经失效,但账号当前 token 有效。代理回源必须用当前 token 覆盖旧值, +// 否则挂载 Emby 的封面会一直 401、下发占位图。 +func TestImageProxyInjectsRemoteEmbyToken(t *testing.T) { + const current = "current-token" + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Emby-Token") != current { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write(testJPEG) + })) + defer upstream.Close() + + u, err := url.Parse(upstream.URL) + if err != nil { + t.Fatal(err) + } + proxy := newUpstreamImageProxy(t, u.Host) + proxy.SetRemoteEmbyAuthProvider(func(context.Context, string) (string, bool) { + return current, true + }) + + raw := upstream.URL + "/emby/Items/abc/Images/Primary?api_key=stale-token" + rec := httptest.NewRecorder() + if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil { + t.Fatal(err) + } + if !bytes.Equal(rec.Body.Bytes(), testJPEG) { + t.Fatalf("body = %x, want the upstream image served with the current token", rec.Body.Bytes()) + } +} + +// TestImageProxyRefreshesRemoteEmbyTokenOn401 覆盖 token 在运行中被撤销的场景: +// 账号当前 token 也被上游拒绝时,代理应重新登录一次并重试,且只刷新一次。 +func TestImageProxyRefreshesRemoteEmbyTokenOn401(t *testing.T) { + const ( + stale = "stale-token" + fresh = "fresh-token" + ) + var mu sync.Mutex + refreshes := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Emby-Token") != fresh { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write(testJPEG) + })) + defer upstream.Close() + + u, err := url.Parse(upstream.URL) + if err != nil { + t.Fatal(err) + } + proxy := newUpstreamImageProxy(t, u.Host) + proxy.SetRemoteEmbyAuthProvider(func(context.Context, string) (string, bool) { + return stale, true + }) + proxy.SetRemoteEmbyAuthRefresher(func(context.Context, string) (string, error) { + mu.Lock() + refreshes++ + mu.Unlock() + return fresh, nil + }) + + raw := upstream.URL + "/emby/Items/abc/Images/Primary?api_key=" + stale + rec := httptest.NewRecorder() + if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil { + t.Fatal(err) + } + if !bytes.Equal(rec.Body.Bytes(), testJPEG) { + t.Fatalf("body = %x, want the upstream image served after re-auth", rec.Body.Bytes()) + } + + mu.Lock() + got := refreshes + mu.Unlock() + if got != 1 { + t.Fatalf("refreshes = %d, want exactly 1 re-auth", got) + } +} + +// TestImageProxyLeavesNonEmbyHostsUntouched 确认新的凭据注入只作用于已配置的 +// 远程 Emby 主机:普通图床(这里用 provider 返回 ok=false 模拟)不受影响。 +func TestImageProxyDoesNotInjectForUnknownHost(t *testing.T) { + var gotHeader string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get("X-Emby-Token") + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write(testJPEG) + })) + defer upstream.Close() + + u, err := url.Parse(upstream.URL) + if err != nil { + t.Fatal(err) + } + proxy := newUpstreamImageProxy(t, u.Host) + proxy.SetRemoteEmbyAuthProvider(func(context.Context, string) (string, bool) { + return "", false + }) + + raw := upstream.URL + "/img/cover.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 gotHeader != "" { + t.Fatalf("X-Emby-Token = %q, want no credential header for a non-Emby host", gotHeader) + } +} diff --git a/internal/service/image_proxy_remote_fetch.go b/internal/service/image_proxy_remote_fetch.go index 29957c6..3ae5f89 100644 --- a/internal/service/image_proxy_remote_fetch.go +++ b/internal/service/image_proxy_remote_fetch.go @@ -71,6 +71,8 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string, return remoteImageFetchResult{}, errImageProxyRequestSetup } applyRemoteImageHeaders(req, host, raw) + // 已挂载的远程 Emby:用账号当前 token 覆盖旧凭据,返回头形式对端优先采纳。 + p.applyRemoteEmbyAuth(ctx, req, host) resp, err := candidate.client.Do(req) if err != nil { @@ -80,6 +82,9 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string, defer resp.Body.Close() if resp.StatusCode >= 400 { p.log.Warn("imageproxy: upstream returned non-OK", zap.String("host", host), zap.String("client", candidate.name), zap.String("status", resp.Status)) + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + return remoteImageFetchResult{}, errImageProxyUnauthorized + } return remoteImageFetchResult{}, errors.New("upstream returned " + resp.Status) } if err := p.streamImageToCache(cachePath, failPath, resp.Body); err != nil { diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index 0e3c670..ebba397 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -237,6 +237,15 @@ func (b *serviceContainerBuilder) initImageProxy() { b.c.ImageProxy.SetAllowedRemoteHostsProvider(func() []string { return b.c.EmbyRemote.ConfiguredRemoteHosts(context.Background()) }) + // 远程 Emby 的图片 URL 会带着签发时的 api_key 长期缓存/下发;轮换后 + // URL 里的旧值必然过期。回源时改用账号当前 token,并在上游 401/403 时 + // 重新登录一次再重试,封面图不再依赖人工改账号。 + b.c.ImageProxy.SetRemoteEmbyAuthProvider(func(ctx context.Context, host string) (string, bool) { + return b.c.EmbyRemote.RemoteEmbyImageTokenForHost(ctx, host) + }) + b.c.ImageProxy.SetRemoteEmbyAuthRefresher(func(ctx context.Context, host string) (string, error) { + return b.c.EmbyRemote.RefreshRemoteEmbyImageTokenForHost(ctx, host) + }) } b.c.Scan.SetImageProxy(b.c.ImageProxy) b.c.Scraper.SetImageProxy(b.c.ImageProxy)