mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 05:16:38 +08:00
优化
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user