mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 13:06:36 +08:00
优化
This commit is contained in:
@@ -21,6 +21,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -237,6 +238,88 @@ func (r *EmbyRemoteService) ConfiguredRemoteHosts(ctx context.Context) []string
|
|||||||
return hosts
|
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)。
|
// AccountByID 按 ID 查找远程 Emby 挂载账号(不存在或类型不符返回 nil)。
|
||||||
func (r *EmbyRemoteService) AccountByID(ctx context.Context, id string) *model.StrmAccount {
|
func (r *EmbyRemoteService) AccountByID(ctx context.Context, id string) *model.StrmAccount {
|
||||||
if strings.TrimSpace(id) == "" {
|
if strings.TrimSpace(id) == "" {
|
||||||
@@ -699,17 +782,23 @@ func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAcc
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if status == http.StatusUnauthorized && attempt == 0 {
|
if status == http.StatusUnauthorized && attempt == 0 {
|
||||||
// 401:只清当前线路的内存 token 并立即重认证;不在此时删除
|
// 401:只清当前线路的内存 token 并立即重认证;不先删 DB 里的
|
||||||
// DB 里的 api_key——①外层还会按线路故障转移(其他线路可能
|
// api_key——纯 api_key 账号删除后无法再认证,一次线路误报就会
|
||||||
// 存有自己的 token);②纯 api_key 账号删除后无法再认证,一次
|
// 把账号“砖化”。重认证成功后把新 token 写回:既让重试用上正确
|
||||||
// 线路误报就会把账号“砖化”。重认证成功后 persistToken 会用
|
// 凭据,也避免每个后续请求都重新登录一次。
|
||||||
// 新 token 覆盖 api_key。
|
previous := cfg.Token
|
||||||
cfg.Token = ""
|
cfg.Token = ""
|
||||||
if err := r.ensureTokenOnLine(ctx, acct, cfg); err != nil {
|
if err := r.ensureTokenOnLine(ctx, acct, cfg); err != nil {
|
||||||
return fmt.Errorf("认证重试失败: %w", err)
|
return fmt.Errorf("认证重试失败: %w", err)
|
||||||
}
|
}
|
||||||
|
if q != nil {
|
||||||
|
q.Set("api_key", cfg.Token)
|
||||||
|
}
|
||||||
master.Token = cfg.Token
|
master.Token = cfg.Token
|
||||||
master.RemoteUserID = cfg.RemoteUserID
|
master.RemoteUserID = cfg.RemoteUserID
|
||||||
|
if cfg.Token != "" && cfg.Token != previous {
|
||||||
|
_ = r.persistToken(ctx, acct, cfg)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if status >= 300 {
|
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
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -65,6 +66,16 @@ type ImageProxy struct {
|
|||||||
allowedHostsMu sync.Mutex
|
allowedHostsMu sync.Mutex
|
||||||
allowedHostsCache map[string]bool
|
allowedHostsCache map[string]bool
|
||||||
allowedHostsAt time.Time
|
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 (
|
const (
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import (
|
|||||||
var errImageProxyRequestSetup = errors.New("image proxy request setup failed")
|
var errImageProxyRequestSetup = errors.New("image proxy request setup failed")
|
||||||
var errImageProxyNonImageContent = errors.New("upstream returned non-image content")
|
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):
|
// prefetchCardResizeOptions 对应前端 ARTWORK.posterCard(见 web/src/api/client.ts):
|
||||||
// 卡片是海报墙最常请求的档位,刮削阶段预生成它能让首个列表请求直接命中缓存。
|
// 卡片是海报墙最常请求的档位,刮削阶段预生成它能让首个列表请求直接命中缓存。
|
||||||
// 若前端调整该预设,这里只是白生成一份用不到的档位(约几十 KB),不影响正确性。
|
// 若前端调整该预设,这里只是白生成一份用不到的档位(约几十 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) {
|
func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, cachePath, failPath string) (remoteImageFetchResult, error) {
|
||||||
var lastErr error
|
result, lastErr := p.fetchRemoteImageAcrossClients(ctx, raw, host, cachePath, failPath)
|
||||||
for _, candidate := range p.remoteImageFetchClients(host) {
|
if lastErr == nil {
|
||||||
result, err := p.fetchRemoteImageOnce(ctx, raw, host, candidate, cachePath, failPath)
|
return result, nil
|
||||||
if err == 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) {
|
if p.canUseExternalImageFallback() && isDoubanImageHost(host) {
|
||||||
data, ctype, _, err := fetchRemoteImageWithCurl(ctx, raw, 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)
|
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
|
// fetchAndCacheRemoteImageShared coalesces concurrent requests for the same
|
||||||
// upstream image. A poster can appear in the hero, a shelf and the detail page
|
// 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
|
// 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
|
return remoteImageFetchResult{}, errImageProxyRequestSetup
|
||||||
}
|
}
|
||||||
applyRemoteImageHeaders(req, host, raw)
|
applyRemoteImageHeaders(req, host, raw)
|
||||||
|
// 已挂载的远程 Emby:用账号当前 token 覆盖旧凭据,返回头形式对端优先采纳。
|
||||||
|
p.applyRemoteEmbyAuth(ctx, req, host)
|
||||||
|
|
||||||
resp, err := candidate.client.Do(req)
|
resp, err := candidate.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -80,6 +82,9 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode >= 400 {
|
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))
|
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)
|
return remoteImageFetchResult{}, errors.New("upstream returned " + resp.Status)
|
||||||
}
|
}
|
||||||
if err := p.streamImageToCache(cachePath, failPath, resp.Body); err != nil {
|
if err := p.streamImageToCache(cachePath, failPath, resp.Body); err != nil {
|
||||||
|
|||||||
@@ -237,6 +237,15 @@ func (b *serviceContainerBuilder) initImageProxy() {
|
|||||||
b.c.ImageProxy.SetAllowedRemoteHostsProvider(func() []string {
|
b.c.ImageProxy.SetAllowedRemoteHostsProvider(func() []string {
|
||||||
return b.c.EmbyRemote.ConfiguredRemoteHosts(context.Background())
|
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.Scan.SetImageProxy(b.c.ImageProxy)
|
||||||
b.c.Scraper.SetImageProxy(b.c.ImageProxy)
|
b.c.Scraper.SetImageProxy(b.c.ImageProxy)
|
||||||
|
|||||||
Reference in New Issue
Block a user