Compare commits

...

5 Commits

Author SHA1 Message Date
truewhile 121a0a3cc5 优化 2026-10-21 10:11:47 +08:00
truewhile 3057664a48 优化 2026-10-21 09:19:14 +08:00
truewhile 7793d50906 优化 2026-10-21 00:06:16 +08:00
truewhile 5ad71b9421 优化查询 2026-10-20 23:29:51 +08:00
truewhile 5615806c2c Merge pull request #49 from truewhile/feat/media-maintenance-and-diagnostics
完善媒体维护、备份恢复与播放诊断
2026-10-20 22:04:10 +08:00
15 changed files with 1118 additions and 54 deletions
+115 -10
View File
@@ -4,6 +4,7 @@ package handler
import (
"context"
"errors"
"fmt"
"math"
"net/http"
"sort"
@@ -41,6 +42,11 @@ type webLibraryPayload struct {
Cards []service.SeriesCardView `json:"cards,omitempty"`
}
// remoteLibraryPreviewBudget 限制 /api/libraries?with_preview=1 里远程 Emby
// 库预览(总条数 + 最新卡片)的总耗时。这些预览只用于首页卡片,值得为它
// 排队等待的量很小;此前它跟随请求上下文,上游超时会让整页等 15 秒以上。
var remoteLibraryPreviewBudget = 3 * time.Second
// remoteLibraryItemTypes 远程库内容拉取时按 CollectionType 过滤直属条目,
// 避免电影库里的合集文件夹(Folder) 漏出为电影卡片。
func remoteLibraryItemTypes(collectionType string) string {
@@ -177,6 +183,12 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
}
if withPreview && len(allowedViews) > 0 {
// 远程库预览只是首页卡片上的「最新几条」,绝不能拖住整个
// 库列表:上游 Emby 一旦超时,每个远程库都要等满
// embyRemoteHTTPTimeout(15s),串起来就是 17–30 秒。
// 给整段预览一个总预算,超时就只返回库名,卡片留给下一次刷新。
previewCtx, cancelPreview := context.WithTimeout(ctx, remoteLibraryPreviewBudget)
defer cancelPreview()
const maxRemotePreviewWorkers = 6
sem := make(chan struct{}, maxRemotePreviewWorkers)
var wg sync.WaitGroup
@@ -188,11 +200,11 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
case <-previewCtx.Done():
return
}
helper.Run(svc.Log, "media.remotePreview", func() {
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
acct := svc.EmbyRemote.AccountByID(previewCtx, v.AccountID)
if acct == nil {
return
}
@@ -204,10 +216,10 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
Name: v.Library.Name,
}
itemTypes := remoteLibraryItemTypes(v.CollectionType)
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(previewCtx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
remotePayloads[i].Total = total
}
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
if cards, err := svc.EmbyRemote.RemoteLatestCards(previewCtx, tmpMount, acct, v.RemoteID, limit); err == nil {
remotePayloads[i].Cards = service.NewSeriesCardViews(cards)
}
})
@@ -705,6 +717,8 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
}
paginated := c.Query("page") != "" || c.Query("page_size") != ""
sortBy := normalizeMediaSearchSort(c.Query("sort"))
// 远程搜索与本地查询并行发起:本地结果不再排在远程超时之后才返回。
remoteCh := startRemoteMediaSearch(ctx, svc, query, visibility, filter)
// Sorting and grouping must operate on the same candidate set before
// either a page size or the caller's output limit is applied.
items, err := svc.Media.SearchMediaVisibleFiltered(ctx, query, 50000, visibility, filter)
@@ -712,12 +726,9 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if svc.EmbyRemote != nil && query != "" && (filter.LibraryID == "" || service.IsEmbyRemoteID(filter.LibraryID)) {
remote, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, query, 50000, visibility)
for _, item := range remote {
if remoteSearchMatchesFilter(item, filter) {
items = append(items, item)
}
if remoteCh != nil {
for _, item := range <-remoteCh {
items = append(items, item)
}
}
if c.DefaultQuery("group_series", "0") != "0" {
@@ -741,6 +752,100 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
}
}
const (
// remoteSearchItemLimit 是每个远程 Emby 挂载点返回的条目上限。此前这里传
// 50000(等于让上游做一次全库查询),上游稍慢就会一路跑到 5 秒超时,把每次
// 搜索都拖成秒级;搜索场景只需要少量候选,多出来的结果也会被分页丢掉。
remoteSearchItemLimit = 100
// remoteSearchBudget 限制远程结果最多给一次请求增加多少延迟。
remoteSearchBudget = 2 * time.Second
// remoteSearchCacheTTL 让同一关键词的翻页与重复搜索复用远程结果,避免每翻
// 一页都重新打一遍远程挂载点。
remoteSearchCacheTTL = 30 * time.Second
// remoteSearchFailureCacheTTL 用于上游超时/失败时的短缓存:部分结果只保留
// 几秒,避免反复吃满 remoteSearchBudget,又不至于长期压住后续重试。
remoteSearchFailureCacheTTL = 5 * time.Second
)
// startRemoteMediaSearch 并发发起远程搜索,返回一个只投递一次结果的通道。
// 不满足远程搜索条件时返回 nil。通道带缓冲,调用方即使提前返回也不会泄漏
// goroutine;请求结束时请求上下文被取消,远程调用会随之退出。
func startRemoteMediaSearch(
ctx context.Context,
svc *service.Container,
query string,
visibility service.MediaVisibility,
filter service.MediaSearchFilter,
) <-chan []model.Media {
if svc == nil || svc.EmbyRemote == nil || query == "" {
return nil
}
if filter.LibraryID != "" && !service.IsEmbyRemoteID(filter.LibraryID) {
return nil
}
results := make(chan []model.Media, 1)
go func() {
results <- remoteMediaSearchItems(ctx, svc, query, visibility, filter)
}()
return results
}
func remoteMediaSearchItems(
ctx context.Context,
svc *service.Container,
query string,
visibility service.MediaVisibility,
filter service.MediaSearchFilter,
) []model.Media {
cacheKey := remoteSearchCacheKey(query, visibility)
if svc.Cache != nil {
if cached, ok := svc.Cache.GetObject(cacheKey); ok {
if rows, ok := cached.([]model.Media); ok {
return filterRemoteSearchItems(rows, filter)
}
}
}
searchCtx, cancel := context.WithTimeout(ctx, remoteSearchBudget)
defer cancel()
remote, _ := svc.EmbyRemote.RemoteSearchMedia(searchCtx, query, remoteSearchItemLimit, visibility)
if svc.Cache != nil {
// 空结果也要缓存:否则查不到片名时每次都会重新等到 remoteSearchBudget。
if remote == nil {
remote = []model.Media{}
}
ttl := remoteSearchCacheTTL
if searchCtx.Err() != nil {
ttl = remoteSearchFailureCacheTTL
}
svc.Cache.SetObject(cacheKey, remote, ttl)
}
return filterRemoteSearchItems(remote, filter)
}
func filterRemoteSearchItems(remote []model.Media, filter service.MediaSearchFilter) []model.Media {
if len(remote) == 0 {
return nil
}
out := make([]model.Media, 0, len(remote))
for _, item := range remote {
if remoteSearchMatchesFilter(item, filter) {
out = append(out, item)
}
}
return out
}
// remoteSearchCacheKey 按可见范围隔离远程搜索结果,避免把某个用户可见挂载
// 点的结果复用给可见范围不同的用户。
func remoteSearchCacheKey(query string, visibility service.MediaVisibility) string {
allowed := append([]string(nil), visibility.AllowedLibraryIDs...)
hidden := append([]string(nil), visibility.HiddenLibraryIDs...)
sort.Strings(allowed)
sort.Strings(hidden)
return fmt.Sprintf("media:obj:remote-search:%t|%s|%s|%s",
visibility.IncludeNSFW, strings.Join(allowed, ","), strings.Join(hidden, ","), query)
}
func writeMediaSearchResults[T any](c *gin.Context, items []T, page, size, limit int, paginated bool) {
if items == nil {
items = []T{}
@@ -0,0 +1,136 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// TestListLibrariesRemotePreviewIsBounded 锁定远程库预览的总预算:
// 上游 Emby 卡住时,/api/libraries?with_preview=1 必须按时返回远程库条目
// (只是没有卡片),而不是跟着上游一起等十几秒。线上实测这个接口因此出现过
// 17.4 秒、30 秒(前端放弃)的响应,设置页也跟着整页空转。
func TestListLibrariesRemotePreviewIsBounded(t *testing.T) {
gin.SetMode(gin.TestMode)
oldBudget := remoteLibraryPreviewBudget
remoteLibraryPreviewBudget = 200 * time.Millisecond
t.Cleanup(func() { remoteLibraryPreviewBudget = oldBudget })
// Views / 用户列表正常返回,Preview(Items 查询)永远不返回:模拟上游半挂。
release := make(chan struct{})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
path := r.URL.Path
switch {
case strings.HasSuffix(path, "/Views"):
_ = json.NewEncoder(w).Encode(map[string]any{
"Items": []map[string]any{{
"Id": "view-1", "Name": "远程动画", "CollectionType": "tvshows",
}},
})
return
case strings.HasSuffix(path, "/Users"):
// api_key 账号要先解析出远程用户 Id,否则请求路径会落到兜底 "0"。
_ = json.NewEncoder(w).Encode([]map[string]any{{"Id": "uid-1", "Name": "admin"}})
return
case r.Method == http.MethodPost:
_ = json.NewEncoder(w).Encode(map[string]any{
"AccessToken": "fake-token",
"User": map[string]any{"Id": "uid-1"},
})
return
}
select {
case <-release:
case <-r.Context().Done():
case <-time.After(30 * time.Second):
}
w.WriteHeader(http.StatusGatewayTimeout)
}))
defer upstream.Close()
defer close(release)
// 唯一库名 + cache=shared:预览协程会并发读库(账号解析)。
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.StrmAccount{}, &model.EmbyMount{}, &model.Setting{}, &model.User{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
if err := db.Create(&model.User{Base: model.Base{ID: "admin-1"}, Username: "admin", Role: "admin"}).Error; err != nil {
t.Fatal(err)
}
rawConfig, _ := json.Marshal(map[string]string{"url": upstream.URL, "token": "fake-token"})
acct := model.StrmAccount{
Base: model.Base{ID: "acct-1"}, Name: "远程Emby", Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig), Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil {
t.Fatal(err)
}
mount := model.EmbyMount{
Base: model.Base{ID: "mount-1"}, AccountID: acct.ID, RemoteViewID: "view-1",
RemoteViewName: "远程动画", CollectionType: "tvshows", Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), &mount); err != nil {
t.Fatal(err)
}
svc := &service.Container{
Repo: repos,
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
EmbyRemote: service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())),
Cache: service.NewRuntimeCacheService(&config.Config{}, zap.NewNop()),
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "admin-1")
c.Set(middleware.CtxUserRole, "admin")
c.Request = httptest.NewRequest(http.MethodGet, "/api/libraries?with_preview=1&include_hidden=1", nil)
start := time.Now()
listLibrariesHandler(svc)(c)
elapsed := time.Since(start)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if elapsed > 2*time.Second {
t.Fatalf("request took %v with a hung upstream; the preview budget must bound it", elapsed)
}
var payload []webLibraryPayload
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v (%s)", err, w.Body.String())
}
var remote *webLibraryPayload
for i := range payload {
if payload[i].IsRemoteEmby {
remote = &payload[i]
break
}
}
if remote == nil {
t.Fatalf("remote library missing from the bounded response: %#v", payload)
}
if len(remote.Cards) != 0 || remote.Total != 0 {
t.Fatalf("expected the preview to be skipped on timeout, got total=%d cards=%d", remote.Total, len(remote.Cards))
}
}
@@ -0,0 +1,153 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/middleware"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
// TestSearchMediaRemoteSearchBoundedAndCached 防止远程搜索再次退化:
// 1. 每个挂载点的 Limit 必须是 remoteSearchItemLimit,而不是历史全库查询的 50000;
// 2. 同一关键词的重复搜索应命中缓存,不再打上游;
// 3. 指定本地媒体库时不应枚举远程挂载点。
func TestSearchMediaRemoteSearchBoundedAndCached(t *testing.T) {
gin.SetMode(gin.TestMode)
var mu sync.Mutex
searchCalls := 0
searchLimits := make([]string, 0, 4)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if term := r.URL.Query().Get("SearchTerm"); term != "" {
mu.Lock()
searchCalls++
searchLimits = append(searchLimits, r.URL.Query().Get("Limit"))
mu.Unlock()
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"TotalRecordCount": 1,
"Items": []map[string]any{{
"Id": "remote-1",
"Name": "远方剧集",
"Type": "Series",
"ProductionYear": 2024,
}},
})
}))
defer upstream.Close()
// 唯一库名 + cache=shared:远程搜索与本地查询会并发读库,
// 而 :memory: 下每个连接各自一个空库,并发连接会读到「no such table」。
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.StrmAccount{}, &model.EmbyMount{}, &model.Setting{}, &model.User{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
if err := repos.DB.Create(&model.User{Base: model.Base{ID: "user-1"}, Username: "admin", Role: "admin"}).Error; err != nil {
t.Fatal(err)
}
lib := model.Library{Name: "本地电影", Path: "/media/movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "local-1"}, LibraryID: lib.ID, Title: "流浪地球", Path: "/media/movies/流浪地球.mkv",
}).Error; err != nil {
t.Fatal(err)
}
rawConfig, _ := json.Marshal(map[string]string{"url": upstream.URL, "token": "fake-token"})
acct := model.StrmAccount{
Base: model.Base{ID: "acct"}, Name: "远程Emby", Provider: model.StrmProviderEmbyRemote,
Config: string(rawConfig), Enabled: true,
}
if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil {
t.Fatal(err)
}
mount := model.EmbyMount{
Base: model.Base{ID: "mount-1"}, AccountID: acct.ID, RemoteViewID: "view-1",
RemoteViewName: "远程剧集", CollectionType: "tvshows", Enabled: true,
}
if err := repos.EmbyMount.Create(t.Context(), &mount); err != nil {
t.Fatal(err)
}
svc := &service.Container{
Repo: repos,
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
EmbyRemote: service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())),
Cache: service.NewRuntimeCacheService(&config.Config{}, zap.NewNop()),
}
search := func(params string) []service.MediaItem {
t.Helper()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set(middleware.CtxUserID, "user-1")
c.Set(middleware.CtxUserRole, "admin")
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?"+params, nil)
searchMediaHandler(svc)(c)
if w.Code != http.StatusOK {
t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String())
}
var response struct {
Items []service.MediaItem `json:"items"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
return response.Items
}
callCount := func() int {
mu.Lock()
defer mu.Unlock()
return searchCalls
}
if items := search("q=远方&page=1&page_size=48"); len(items) != 1 || items[0].Title != "远方剧集" {
t.Fatalf("expected remote result, got %#v", items)
}
if calls := callCount(); calls != 1 {
t.Fatalf("expected exactly one upstream search, got %d", calls)
}
mu.Lock()
limit := searchLimits[0]
mu.Unlock()
if limit != "100" {
t.Fatalf("remote search Limit=%q, want bounded 100 (50000 means a full-library query)", limit)
}
// 同一关键词再来一次(例如刷新/重复搜索)应复用缓存,不再请求上游。
if items := search("q=远方&page=1&page_size=48"); len(items) != 1 || items[0].Title != "远方剧集" {
t.Fatalf("cached search lost the remote result: %#v", items)
}
if calls := callCount(); calls != 1 {
t.Fatalf("expected cached remote search to skip upstream, got %d calls", calls)
}
// 指定本地媒体库时不应枚举远程挂载点。
if items := search("q=流浪&page=1&page_size=48&library_id=" + lib.ID); len(items) != 1 || items[0].ID != "local-1" {
t.Fatalf("expected local-only result, got %#v", items)
}
if calls := callCount(); calls != 1 {
t.Fatalf("local library filter must not hit upstream, got %d calls", calls)
}
}
+5 -1
View File
@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/gin-gonic/gin"
@@ -35,7 +36,10 @@ func TestSearchMediaSeriesKeepsRemoteLibrariesSeparate(t *testing.T) {
}))
defer upstream.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
// 唯一库名 + cache=shared:远程搜索与本地查询会并发读库,
// 而 :memory: 下每个连接各自一个空库,并发连接会读到「no such table」。
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
+4 -1
View File
@@ -559,7 +559,10 @@ func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) {
}))
defer server.Close()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
// 唯一库名 + cache=shared:远程搜索与本地查询会并发读库,
// 而 :memory: 下每个连接各自一个空库,并发连接会读到「no such table」。
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
@@ -24,6 +24,14 @@ func (s *ApiConfigService) TestConnection(ctx context.Context, provider string)
switch provider {
case "tmdb":
return s.testTMDb(cfg)
case "bangumi":
return s.testBangumi(ctx, cfg)
case "thetvdb":
return s.testTheTVDB(ctx, cfg)
case "fanart":
return s.testFanart(ctx, cfg)
case "douban":
return s.testDouban(ctx, cfg)
case "openai":
return s.testOpenAI(cfg)
case "deepseek":
@@ -0,0 +1,191 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/truewhile/MeBox/internal/model"
)
// Bangumi's public metadata endpoints work without a token. If one was saved,
// also check /v0/me: public endpoints alone may accept an expired token.
func (s *ApiConfigService) testBangumi(ctx context.Context, cfg *model.APIConfig) (string, error) {
req, err := newMetadataProbeRequest(ctx, cfg, "https://api.bgm.tv", "/v0/subjects/253", http.MethodGet, nil)
if err != nil {
return "error", err
}
if token := strings.TrimSpace(cfg.APIKey); token != "" {
req.Header.Set("Authorization", "Bearer "+token)
identityReq := req.Clone(ctx)
identityURL := *req.URL
identityURL.Path = strings.TrimSuffix(identityURL.Path, "/subjects/253") + "/me"
identityReq.URL = &identityURL
var identity struct {
ID int `json:"id"`
}
if result, err := testMetadataProbeJSON(identityReq, "Bangumi", &identity); err != nil {
return result, err
}
if identity.ID <= 0 {
return "error", errors.New("Bangumi returned an invalid user response")
}
}
var subject struct {
ID int `json:"id"`
Name string `json:"name"`
}
if result, err := testMetadataProbeJSON(req, "Bangumi", &subject); err != nil {
return result, err
}
if subject.ID != 253 || strings.TrimSpace(subject.Name) == "" {
return "error", errors.New("Bangumi returned an invalid subject response")
}
return "success", nil
}
func (s *ApiConfigService) testTheTVDB(ctx context.Context, cfg *model.APIConfig) (string, error) {
key := strings.TrimSpace(cfg.APIKey)
if key == "" {
return "error", errors.New("TheTVDB API key is required")
}
body, err := json.Marshal(map[string]string{"apikey": key})
if err != nil {
return "error", err
}
req, err := newMetadataProbeRequest(ctx, cfg, "https://api4.thetvdb.com/v4", "/login", http.MethodPost, strings.NewReader(string(body)))
if err != nil {
return "error", err
}
req.Header.Set("Content-Type", "application/json")
var response struct {
Status string `json:"status"`
Data struct {
Token string `json:"token"`
} `json:"data"`
}
if result, err := testMetadataProbeJSON(req, "TheTVDB", &response); err != nil {
return result, err
}
if response.Status != "success" || strings.TrimSpace(response.Data.Token) == "" {
return "error", errors.New("TheTVDB login did not return a valid token")
}
return "success", nil
}
func (s *ApiConfigService) testFanart(ctx context.Context, cfg *model.APIConfig) (string, error) {
key := strings.TrimSpace(cfg.APIKey)
if key == "" {
return "error", errors.New("Fanart API key is required")
}
req, err := newMetadataProbeRequest(ctx, cfg, "https://webservice.fanart.tv/v3", "/movies/550", http.MethodGet, nil)
if err != nil {
return "error", err
}
query := req.URL.Query()
query.Set("api_key", key)
req.URL.RawQuery = query.Encode()
var response struct {
TMDbID json.Number `json:"tmdb_id"`
Name string `json:"name"`
}
if result, err := testMetadataProbeJSON(req, "Fanart", &response); err != nil {
return result, err
}
if response.TMDbID.String() != "550" || strings.TrimSpace(response.Name) == "" {
return "error", errors.New("Fanart returned an invalid movie response")
}
return "success", nil
}
func (s *ApiConfigService) testDouban(ctx context.Context, cfg *model.APIConfig) (string, error) {
req, err := newMetadataProbeRequest(ctx, cfg, "https://movie.douban.com", "/j/subject_suggest", http.MethodGet, nil)
if err != nil {
return "error", err
}
query := req.URL.Query()
query.Set("q", "Inception")
req.URL.RawQuery = query.Encode()
req.Header.Set("User-Agent", userAgents[0])
req.Header.Set("Referer", "https://movie.douban.com/")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
if cookie := strings.TrimSpace(cfg.APIKey); cookie != "" {
req.Header.Set("Cookie", cookie)
}
var suggestions []struct {
ID string `json:"id"`
Title string `json:"title"`
}
if result, err := testMetadataProbeJSON(req, "Douban", &suggestions); err != nil {
return result, err
}
// An empty array is a valid search result; null or an error object is not.
if suggestions == nil {
return "error", errors.New("Douban returned an invalid search response (login or verification may be required)")
}
for _, suggestion := range suggestions {
if suggestion.ID == "" || strings.TrimSpace(suggestion.Title) == "" {
return "error", errors.New("Douban returned an invalid search suggestion")
}
}
return "success", nil
}
func newMetadataProbeRequest(ctx context.Context, cfg *model.APIConfig, defaultBase, path, method string, body io.Reader) (*http.Request, error) {
base := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
if base == "" {
base = defaultBase
}
req, err := http.NewRequestWithContext(ctx, method, base+path, body)
if err != nil {
// The saved base URL may contain credentials. Never echo it to the UI.
return nil, fmt.Errorf("%s: invalid base URL", cfg.Provider)
}
req.Header.Set("User-Agent", "MeBox/0.1 (https://github.com/truewhile/MeBox)")
req.Header.Set("Accept", "application/json")
return req, nil
}
// Decode a bounded, complete JSON document rather than accepting any HTTP 200
// (reverse proxies and anti-bot/login pages often return 200 as well).
func testMetadataProbeJSON(req *http.Request, provider string, out any) (string, error) {
client := NewExternalHTTPClient(10 * time.Second)
defer client.CloseIdleConnections()
resp, err := client.Do(req)
if err != nil {
// url.Error includes the full request URL, including Fanart's API key.
var urlErr *url.Error
if errors.As(err, &urlErr) {
err = urlErr.Err
}
return "error", fmt.Errorf("%s connection failed: %w", provider, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
return "invalid", fmt.Errorf("%s authentication failed (HTTP 401): check configured credentials", provider)
}
if resp.StatusCode == http.StatusForbidden {
return "error", fmt.Errorf("%s access denied (HTTP 403): check credentials, permissions or anti-bot verification", provider)
}
if resp.StatusCode != http.StatusOK {
return "error", fmt.Errorf("%s API returned status %d", provider, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, (1<<20)+1))
if err != nil {
return "error", fmt.Errorf("%s response read failed: %w", provider, err)
}
if len(body) > 1<<20 {
return "error", fmt.Errorf("%s response exceeds 1 MiB", provider)
}
if err := json.Unmarshal(body, out); err != nil {
// Do not include the response body: it may echo credentials.
return "error", fmt.Errorf("%s returned invalid JSON (login or verification page may have intercepted the request)", provider)
}
return "success", nil
}
@@ -0,0 +1,252 @@
package service
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/repository"
)
type metadataProbeFixture struct {
provider string
path string
key string
body string
}
var metadataProbeFixtures = []metadataProbeFixture{
{"bangumi", "/v0/subjects/253", "", `{"id":253,"name":"カウボーイビバップ","type":2}`},
{"thetvdb", "/login", "tvdb-test-secret", `{"status":"success","data":{"token":"test-jwt"}}`},
{"fanart", "/movies/550", "fanart+test&secret", `{"name":"Fight Club","tmdb_id":"550","imdb_id":"tt0137523","movieposter":[]}`},
{"douban", "/j/subject_suggest", "bid=test-cookie", `[{"id":"3541415","title":"盗梦空间","year":"2010","type":"movie"}]`},
}
func newMetadataConnectionTestService(t *testing.T) *ApiConfigService {
t.Helper()
log := zap.NewNop()
return NewApiConfigService(&config.Config{}, log, repository.New(newServiceTestDB(t)), NewCryptoService("metadata-test-secret", log))
}
func saveMetadataProbeConfig(t *testing.T, svc *ApiConfigService, provider, baseURL, key string) {
t.Helper()
if _, err := svc.Upsert(t.Context(), provider, key, baseURL, "", true); err != nil {
t.Fatal(err)
}
}
// Removing a dispatch branch, ignoring the saved URL/key, or using the wrong
// endpoint must fail this integration test (real database and HTTP server).
func TestMetadataConnectionUsesSavedConfig(t *testing.T) {
svc := newMetadataConnectionTestService(t)
for _, fixture := range metadataProbeFixtures {
t.Run(fixture.provider, func(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/proxy"+fixture.path {
t.Errorf("request path = %q", r.URL.Path)
}
if r.Header.Get("User-Agent") == "" {
t.Error("missing User-Agent")
}
if fixture.provider == "thetvdb" {
var body map[string]string
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Error(err)
}
if r.Method != http.MethodPost || body["apikey"] != fixture.key || r.Header.Get("Content-Type") != "application/json" {
t.Error("TheTVDB login must POST the saved, decrypted API key as JSON")
}
} else if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
if fixture.provider == "fanart" && r.URL.Query().Get("api_key") != fixture.key {
t.Error("Fanart API key was not escaped/passed correctly")
}
if fixture.provider == "douban" {
if r.Header.Get("Cookie") != fixture.key || r.Header.Get("Referer") != "https://movie.douban.com/" || r.URL.Query().Get("q") == "" {
t.Error("Douban search must carry the configured cookie, referer and query")
}
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(fixture.body))
}))
defer server.Close()
saveMetadataProbeConfig(t, svc, fixture.provider, " "+server.URL+"/proxy/ ", fixture.key)
result, err := svc.TestConnection(t.Context(), fixture.provider)
if err != nil || result != "success" {
t.Fatalf("TestConnection = %q, %v, want success", result, err)
}
if calls != 1 {
t.Errorf("HTTP calls = %d, want 1", calls)
}
})
}
}
func TestMetadataConnectionRejectsBadResponses(t *testing.T) {
svc := newMetadataConnectionTestService(t)
for _, fixture := range metadataProbeFixtures {
for _, response := range []struct {
name string
status int
body string
result string
}{
{"unauthorized", 401, `{"error":"denied"}`, "invalid"},
{"forbidden", 403, `<html>Access denied</html>`, "error"},
{"rate limited", 429, `{}`, "error"},
{"upstream error", 503, `{}`, "error"},
{"challenge page", 200, `<html>Please log in or complete verification</html>`, "error"},
{"empty body", 200, ``, "error"},
{"wrong schema", 200, `{"error":"intercepted"}`, "error"},
{"null", 200, `null`, "error"},
{"trailing garbage", 200, fixture.body + `<html>blocked</html>`, "error"},
} {
t.Run(fixture.provider+"/"+response.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(response.status)
_, _ = w.Write([]byte(response.body))
}))
defer server.Close()
saveMetadataProbeConfig(t, svc, fixture.provider, server.URL, fixture.key)
result, err := svc.TestConnection(t.Context(), fixture.provider)
if err == nil || result != response.result {
t.Fatalf("TestConnection = %q, %v, want %q and error", result, err, response.result)
}
if fixture.key != "" && strings.Contains(err.Error(), fixture.key) {
t.Error("error exposes configured credentials")
}
})
}
}
}
func TestMetadataConnectionRequiresAPIKeys(t *testing.T) {
svc := newMetadataConnectionTestService(t)
for _, provider := range []string{"thetvdb", "fanart"} {
t.Run(provider, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("must reject missing key before making a request")
}))
defer server.Close()
saveMetadataProbeConfig(t, svc, provider, server.URL, " ")
result, err := svc.TestConnection(t.Context(), provider)
if result != "error" || err == nil || !strings.Contains(err.Error(), "API key") {
t.Fatalf("TestConnection = %q, %v, want missing API key error", result, err)
}
})
}
}
func TestMetadataConnectionBangumiValidatesConfiguredToken(t *testing.T) {
svc := newMetadataConnectionTestService(t)
for _, valid := range []bool{true, false} {
name := "valid"
if !valid {
name = "invalid"
}
t.Run(name, func(t *testing.T) {
meCalls, subjectCalls := 0, 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer bangumi-test-token" {
t.Error("missing saved Bangumi bearer token")
}
switch r.URL.Path {
case "/v0/me":
meCalls++
if !valid {
w.WriteHeader(http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte(`{"id":42,"username":"test-user"}`))
case "/v0/subjects/253":
subjectCalls++
_, _ = w.Write([]byte(`{"id":253,"name":"カウボーイビバップ"}`))
default:
t.Errorf("unexpected path: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
saveMetadataProbeConfig(t, svc, "bangumi", server.URL, "bangumi-test-token")
result, err := svc.TestConnection(t.Context(), "bangumi")
if valid && (result != "success" || err != nil || subjectCalls != 1) {
t.Fatalf("valid token: result=%q err=%v subjectCalls=%d", result, err, subjectCalls)
}
if !valid && (result != "invalid" || err == nil || subjectCalls != 0) {
t.Fatalf("invalid token: result=%q err=%v subjectCalls=%d", result, err, subjectCalls)
}
if meCalls != 1 {
t.Errorf("identity requests = %d, want 1", meCalls)
}
})
}
}
func TestMetadataConnectionDoubanDoesNotRequireCookie(t *testing.T) {
svc := newMetadataConnectionTestService(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Cookie") != "" {
t.Error("unexpected Cookie for public search")
}
_, _ = w.Write([]byte(`[]`))
}))
defer server.Close()
saveMetadataProbeConfig(t, svc, "douban", server.URL, "")
result, err := svc.TestConnection(t.Context(), "douban")
if result != "success" || err != nil {
t.Fatalf("public search with empty results = %q, %v", result, err)
}
}
func TestMetadataConnectionHonorsContext(t *testing.T) {
svc := newMetadataConnectionTestService(t)
for _, fixture := range metadataProbeFixtures {
t.Run(fixture.provider, func(t *testing.T) {
started := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
close(started)
<-r.Context().Done()
}))
defer server.Close()
saveMetadataProbeConfig(t, svc, fixture.provider, server.URL, fixture.key)
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
defer cancel()
go func() {
select {
case <-started:
cancel()
case <-ctx.Done():
}
}()
result, err := svc.TestConnection(ctx, fixture.provider)
if result != "error" || !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled request = %q, %v, want context.Canceled", result, err)
}
})
}
}
func TestMetadataConnectionTransportErrorsHideKeys(t *testing.T) {
svc := newMetadataConnectionTestService(t)
server := httptest.NewServer(http.NotFoundHandler())
server.Close()
key := "sensitive-fanart-key"
saveMetadataProbeConfig(t, svc, "fanart", server.URL, key)
result, err := svc.TestConnection(t.Context(), "fanart")
if result != "error" || err == nil || strings.Contains(err.Error(), key) {
t.Fatalf("transport failure = %q, %v, want error without credentials", result, err)
}
}
+10
View File
@@ -49,6 +49,10 @@ type ImageProxy struct {
resizeSemMu sync.Mutex
resizeSem chan struct{}
// resizeQueueWait 是一次请求最多为一格解码槽排队的时长。为 0 时用
// defaultResizeQueueWait;测试会把它调小以免真的等待两秒。
resizeQueueWait time.Duration
// libraryRootsFn returns the configured media library roots so that
// sidecar poster/artwork files stored alongside media (under arbitrary
// per-library paths) are allowed by isAllowedLocalPath. It is provided
@@ -86,6 +90,12 @@ const (
// imageRenditionCacheSubdir 存放挂载 Emby 直接按尺寸产出的成品图:它们
// 已经是客户端要的最终尺寸,和派生缩略图一样属于长期保留的热路径缓存。
imageRenditionCacheSubdir = "renditions"
// defaultResizeQueueWait 是请求为一格解码槽排队的最长时间。一张 3MP
// 海报的解码+缩放约 0.15s,2 核机器两格槽位足以在几秒内消化一整屏
// 海报;等待远好于「槽位忙就回退多兆字节原图」(那样每张图在外部网络
// 下要十几秒)。超时后仍回退原图,保证请求不会被无限期挂住。
defaultResizeQueueWait = 3 * time.Second
)
// NewImageProxy is the constructor.
+79 -14
View File
@@ -17,6 +17,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
"go.uber.org/zap"
@@ -138,8 +139,8 @@ func fitSize(srcW, srcH, maxW, maxH int) (int, int) {
var errImageResizeTooLarge = errors.New("image source exceeds resize pixel budget")
// resizeImageData 按选项缩放图片并重新编码。返回 unchanged=true 表示原图
// 本身已满足目标尺寸,调用方应直接输出原始字节(不重复编码、不损失质量)。
// resizeImageData 按选项缩放图片并重新编码。返回 unchanged=true 表示源图
// 已经是可直接下发的紧凑字节,调用方应直接输出原始字节(不重复编码)。
func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype string, unchanged bool, err error) {
if len(data) == 0 || !o.active() {
return data, detectContentType(data), true, nil
@@ -151,12 +152,21 @@ func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype strin
if cfg.Width <= 0 || cfg.Height <= 0 {
return nil, "", false, errors.New("invalid image dimensions")
}
// 先用 DecodeConfig 判断是否需要解码整图:图已够小就零成本返回原字节。
// 先用 DecodeConfig 判断是否需要缩放。尺寸已在目标框内时仍要重编码:
// 刮削来源的海报常是 1000×1500、1MB 以上的 PNG,直接原样下发会让一处
// 海报墙就从「几十 KB 缩略图」变成「几十 MB 原图」——外部网络下每张
// 都要十几秒。只有本来就是紧凑 Web 格式(JPEG/WebP)且体积很小的源图
// 才值得零成本直出。
dstW, dstH := fitSize(cfg.Width, cfg.Height, o.MaxWidth, o.MaxHeight)
if dstW == cfg.Width && dstH == cfg.Height {
unchangedSize := dstW == cfg.Width && dstH == cfg.Height
if unchangedSize && isCompactWebFormat(format) && len(data) <= compactImageSkipBytes {
return data, detectContentType(data), true, nil
}
if int64(cfg.Width)*int64(cfg.Height) > imageResizeMaxSourcePixels {
if unchangedSize {
// 尺寸已满足但像素量超出解码预算:宁可直出原图,也不冒 OOM 风险。
return data, detectContentType(data), true, nil
}
return nil, "", false, errImageResizeTooLarge
}
@@ -165,7 +175,12 @@ func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype strin
return nil, "", false, err
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
resizeScaled(dst, src, cfg.Width, cfg.Height)
if unchangedSize {
// 不缩放时省掉一次全图重采样,只做格式转换。
draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src)
} else {
resizeScaled(dst, src, cfg.Width, cfg.Height)
}
// 只有可能带透明的源格式才需要逐像素确认,避免 JPEG 的无谓遍历。
// 写实海报的 PNG 通常比等价 JPEG 大一个数量级,因此在确认不含透明
@@ -231,9 +246,12 @@ func isOpaqueImage(img *image.RGBA) bool {
// resizeSourceKey 生成源图身份键:只覆盖源文件身份(路径 + 大小 + mtime),不含
// 目标尺寸。同一源图的所有尺寸档位共用这个前缀,才能互相列举与派生(小图直接
// 从已缓存的大图缩小,而不是重新解码原图)。
//
// 前缀里的 v3 是缓存格式版本:v2 时期「尺寸已在目标框内」的 PNG 会原样直出、
// 不写缓存,而现在同样条件会重编码成 JPEG,旧缓存与新结果语义不同,必须换键。
func (o imageResizeOptions) resizeSourceKey(sourceID string, stat os.FileInfo) string {
h := sha256.New()
_, _ = fmt.Fprintf(h, "v2|%s", sourceID)
_, _ = fmt.Fprintf(h, "v3|%s", sourceID)
if stat != nil {
_, _ = fmt.Fprintf(h, "|%d|%d", stat.Size(), stat.ModTime().UnixNano())
}
@@ -356,13 +374,53 @@ func (p *ImageProxy) tryAcquireResizeSlot() (func(), bool) {
}
}
// acquireResizeSlotFor 对中等体积的图只尝试一次槽位。忙则放弃缩放,
// 避免背景图墙在 2 核机器上排成数秒。超大原图仍等待,以免直出多兆字节。
func (p *ImageProxy) resizeQueueTimeout() time.Duration {
if p != nil && p.resizeQueueWait > 0 {
return p.resizeQueueWait
}
return defaultResizeQueueWait
}
// acquireResizeSlotFor 对紧凑小图只尝试一次槽位,其余情况最多排队
// resizeQueueTimeout。
//
// 从前这里对 1.5MB 以下的图一律「槽位忙就跳过缩放」,结果是并发加载海报墙
// 时几乎每张图都回退成 1MB 上下的 PNG 原图:一页要传几十 MB,外部网络下单张
// 图就要二十多秒。缩略图本身只有几十 KB,排队几百毫秒明显更划算;只有真正
// 大到会拖慢页面的原图才值得在超时后直出。
func (p *ImageProxy) acquireResizeSlotFor(ctx context.Context, size int64) (func(), bool) {
if size > 0 && size <= resizeQueueBypassBytes {
if size > 0 && size <= compactImageSkipBytes {
return p.tryAcquireResizeSlot()
}
return p.acquireResizeSlot(ctx)
return p.acquireResizeSlotWithin(ctx, p.resizeQueueTimeout())
}
// acquireResizeSlotWithin 在 timeout 内等待一格解码槽;超时或请求取消返回
// false,调用方回退原图。
func (p *ImageProxy) acquireResizeSlotWithin(ctx context.Context, timeout time.Duration) (func(), bool) {
if p == nil {
return func() {}, true
}
if timeout <= 0 {
return p.tryAcquireResizeSlot()
}
p.resizeSemMu.Lock()
if p.resizeSem == nil {
p.resizeSem = make(chan struct{}, imageResizeConcurrency())
}
sem := p.resizeSem
p.resizeSemMu.Unlock()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case sem <- struct{}{}:
return func() { <-sem }, true
case <-timer.C:
return nil, false
case <-ctx.Done():
return nil, false
}
}
// serveResizedFromFile 从 srcPath 读取图片,按选项缩放后写出,并把结果缓存
@@ -472,10 +530,6 @@ func (p *ImageProxy) ensureResizeCache(ctx context.Context, srcPath string, o im
// ponytail: 200KB 覆盖这台机器上的典型海报(平均约 100KB);更大的图仍尝试缩放。
const compactImageSkipBytes = 200 * 1024
// resizeQueueBypassBytes 是“槽位忙就放弃缩放”的上限。超过它的原图继续排队,
// 以免把未压缩的剧照直接发给客户端。
const resizeQueueBypassBytes = 1536 * 1024
// serveCompactOriginal 在源文件已经很小且是浏览器可直接显示的 JPEG/WebP 时
// 跳过解码。返回 false 表示仍应走缩放路径。
func serveCompactOriginal(w http.ResponseWriter, r *http.Request, srcPath string, stat os.FileInfo) bool {
@@ -504,6 +558,17 @@ func isCompactWebImage(header []byte) bool {
string(header[8:12]) == "WEBP"
}
// isCompactWebFormat 判断 image.DecodeConfig 报告的格式是否已经适合直接下发
// 给浏览器。PNG/BMP/GIF 即使尺寸很小也可能有几百 KB,需要重编码成 JPEG。
func isCompactWebFormat(format string) bool {
switch strings.ToLower(format) {
case "jpeg", "jpg", "webp":
return true
default:
return false
}
}
// writeResizeCache 原子写入缩放结果;失败只记日志,不影响本次响应。
// 与 writeImageCache 一样不再持有全局锁:临时文件名唯一、rename 原子,
// 持锁只会把缩略图的写盘和原图的写盘串成一条队。
+120 -10
View File
@@ -123,17 +123,58 @@ func TestResizeImageDataScalesDownAndReencodesAsJPEG(t *testing.T) {
}
}
func TestResizeImageDataLeavesImagesWithinBoundsUntouched(t *testing.T) {
// 尺寸已经在目标框内的 PNG 也必须转成 JPEG。刮削来的海报常是 1000×1500、
// 1MB 级的 PNG;此前这种情况原样直出,一处海报墙就要下载几十 MB,外部网络下
// 每张图要十几秒(线上实测单张 1.3MB / 20 多秒)。
func TestResizeImageDataReencodesPNGWithinBounds(t *testing.T) {
data := encodeTestPNG(t, 200, 300, 255)
out, _, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, MaxHeight: 600})
out, ctype, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, MaxHeight: 600, Quality: 80})
if err != nil {
t.Fatalf("resize: %v", err)
}
if unchanged {
t.Fatal("PNG within the target box must still be re-encoded as a compact web format")
}
if ctype != "image/jpeg" {
t.Fatalf("content type = %q, want image/jpeg", ctype)
}
cfg, format, err := image.DecodeConfig(bytes.NewReader(out))
if err != nil {
t.Fatalf("decode re-encoded image: %v", err)
}
if format != "jpeg" {
t.Fatalf("format = %q, want jpeg", format)
}
if cfg.Width != 200 || cfg.Height != 300 {
t.Fatalf("re-encoded to %dx%d, want the original 200x300 (no upscaling)", cfg.Width, cfg.Height)
}
}
// 已经是紧凑 Web 格式且体积很小的源图仍然零成本直出。
func TestResizeImageDataLeavesSmallCompactSourceUntouched(t *testing.T) {
var buf bytes.Buffer
img := image.NewRGBA(image.Rect(0, 0, 200, 300))
for y := 0; y < 300; y++ {
for x := 0; x < 200; x++ {
img.Set(x, y, color.RGBA{R: uint8(x % 16 * 16), G: uint8(y % 16 * 16), B: 200, A: 255})
}
}
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
t.Fatalf("encode test jpeg: %v", err)
}
data := buf.Bytes()
if len(data) > compactImageSkipBytes {
t.Fatalf("test jpeg is %d bytes, expected a compact source", len(data))
}
out, ctype, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, MaxHeight: 600})
if err != nil {
t.Fatalf("resize: %v", err)
}
if !unchanged {
t.Fatal("expected an image already within bounds to be returned unchanged")
t.Fatal("a compact JPEG already within bounds should be served unchanged")
}
if !bytes.Equal(out, data) {
t.Fatal("expected the original bytes to be returned verbatim")
if !bytes.Equal(out, data) || ctype != "image/jpeg" {
t.Fatalf("expected the original jpeg bytes back, got %d bytes of %q", len(out), ctype)
}
}
@@ -302,7 +343,9 @@ func TestServeResizedFromFileSkipsDecodeForCompactJPEG(t *testing.T) {
}
}
func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) {
// 排队上限:槽位一直不空时,请求最多等 resizeQueueWait,然后回退原图,
// 不会被无限期挂住。这条约束保护的是请求本身,而不是「不等就直出原图」。
func TestServeResizedFromFileFallsBackWhenResizeQueueStaysBusy(t *testing.T) {
dir := t.TempDir()
src := dir + string(os.PathSeparator) + "backdrop.jpg"
data := make([]byte, compactImageSkipBytes+1024)
@@ -310,7 +353,7 @@ func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) {
if err := os.WriteFile(src, data, 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache", resizeQueueWait: 50 * time.Millisecond}
var releases []func()
for i := 0; i < imageResizeConcurrency(); i++ {
release, ok := proxy.acquireResizeSlot(context.Background())
@@ -327,10 +370,77 @@ func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) {
start := time.Now()
handled := proxy.serveResizedFromFile(httptest.NewRecorder(), httptest.NewRequest("GET", "/x?maxWidth=480", nil), src, imageResizeOptions{MaxWidth: 480})
if time.Since(start) > 200*time.Millisecond {
t.Fatal("resize slot was busy but the request still waited in the queue")
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
t.Fatalf("request waited %v with every slot held; the queue wait must be bounded", elapsed)
}
if handled {
t.Fatal("expected a busy resize slot to fall back so the caller can serve the original")
t.Fatal("expected a permanently busy queue to fall back so the caller can serve the original")
}
}
// 槽位只是短暂忙碌时必须排队等待并产出缩略图,而不是直出多兆字节原图。
// 这是线上「海报墙每张图 20 多秒」的根因:并发加载时槽位全忙,旧逻辑把
// 1MB 级 PNG 原样下发。
// encodeNoisyPNG 生成难以压缩的 PNG:规则色块会被压到 1KB 级别,测不出
// 「大体积 PNG 被原样下发」这条回归。
func encodeNoisyPNG(t *testing.T, w, h int) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
seed := uint32(0x9e3779b9)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
seed = seed*1664525 + 1013904223
img.Set(x, y, color.RGBA{R: uint8(seed >> 24), G: uint8(seed >> 16), B: uint8(seed >> 8), A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
t.Fatalf("encode noisy png: %v", err)
}
return buf.Bytes()
}
func TestServeResizedFromFileWaitsForSlotInsteadOfServingOriginal(t *testing.T) {
dir := t.TempDir()
src := dir + string(os.PathSeparator) + "poster.png"
original := encodeNoisyPNG(t, 378, 540)
if len(original) <= compactImageSkipBytes {
t.Fatalf("test poster is %d bytes, want a source above the compact threshold", len(original))
}
if err := os.WriteFile(src, original, 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
var releases []func()
for i := 0; i < imageResizeConcurrency(); i++ {
release, ok := proxy.acquireResizeSlot(context.Background())
if !ok {
t.Fatal("expected a resize slot")
}
releases = append(releases, release)
}
go func() {
time.Sleep(40 * time.Millisecond)
releases[0]()
}()
rec := httptest.NewRecorder()
if !proxy.serveResizedFromFile(rec, httptest.NewRequest("GET", "/x?maxWidth=480&maxHeight=600", nil), src, imageResizeOptions{MaxWidth: 480, MaxHeight: 600}) {
t.Fatal("expected the request to wait for the slot and serve a thumbnail")
}
body := rec.Body.Bytes()
if bytes.Equal(body, original) {
t.Fatal("served the multi-hundred-KB PNG original instead of a re-encoded thumbnail")
}
if rec.Header().Get("Content-Type") != "image/jpeg" {
t.Fatalf("content type = %q, want image/jpeg", rec.Header().Get("Content-Type"))
}
// 同一请求第二次必须直接命中缩放缓存。
rec2 := httptest.NewRecorder()
if !proxy.serveResizedFromFile(rec2, httptest.NewRequest("GET", "/x?maxWidth=480&maxHeight=600", nil), src, imageResizeOptions{MaxWidth: 480, MaxHeight: 600}) {
t.Fatal("expected the cached thumbnail to be reused")
}
if !bytes.Equal(rec2.Body.Bytes(), body) {
t.Fatal("expected the cached thumbnail bytes to be identical")
}
}
+16 -13
View File
@@ -37,7 +37,7 @@ import {
type SubtitlePosition,
type SubtitleStylePreset,
} from '../utils/subtitleDisplay'
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMedia, type PlayerMode } from './playerPageModel'
import { pickPlayerMode, needsTranscodeForBrowser, isDirectStreamMedia, isStrmMedia, shouldShowPlayerDiagnostics, type PlayerMode } from './playerPageModel'
import { classifyDirectPlayError, directPlayFailureCopy, directStallTimeoutMs, DIRECT_SEEK_GRACE_MS } from './directPlayError'
import type { DirectPlayFailureReason } from './directPlayError'
import { canSeekDirectNow, DIRECT_SEEK_WAIT_MS } from './directPlaySeek'
@@ -181,6 +181,7 @@ export function PlayerPage() {
const [playerError, setPlayerError] = useState('')
// 媒体元数据加载失败(404 / 无权限等):舞台区直接展示错误而不是永远「加载中」
const [loadError, setLoadError] = useState('')
const diagnosticsVisible = shouldShowPlayerDiagnostics(playerError, loadError)
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
const [directOnly, setDirectOnly] = useState(false)
const [directOnlyKnown, setDirectOnlyKnown] = useState(false)
@@ -2608,17 +2609,19 @@ export function PlayerPage() {
}
/>
) : null}
<button
type="button"
onClick={() => setDiagnosticsOpen((open) => !open)}
className="fixed bottom-5 right-4 z-[80] inline-flex items-center gap-2 rounded-full border border-white/15 bg-black/75 px-3 py-2 text-xs font-semibold text-white shadow-lg backdrop-blur hover:bg-black/90"
title="播放诊断"
aria-label="播放诊断"
>
<Activity size={15} />诊断
</button>
{diagnosticsOpen && (
<aside className="fixed bottom-16 right-4 z-[80] max-h-[70vh] w-[min(92vw,420px)] overflow-hidden rounded-2xl border border-white/15 bg-[#111827]/95 text-white shadow-2xl backdrop-blur">
{diagnosticsVisible && (
<button
type="button"
onClick={() => setDiagnosticsOpen((open) => !open)}
className="absolute right-2.5 top-14 z-[80] inline-flex items-center gap-2 rounded-full border border-white/15 bg-black/75 px-3 py-2 text-xs font-semibold text-white shadow-lg backdrop-blur hover:bg-black/90 sm:right-3.5 sm:top-16"
title="播放诊断"
aria-label="播放诊断"
>
<Activity size={15} />诊断
</button>
)}
{diagnosticsVisible && diagnosticsOpen && (
<aside className="absolute right-2.5 top-[6.75rem] z-[80] flex max-h-[calc(100%-8.5rem)] w-[min(92vw,420px)] flex-col overflow-hidden rounded-2xl border border-white/15 bg-[#111827]/95 text-white shadow-2xl backdrop-blur sm:right-3.5 sm:top-[7.25rem]">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<div><h2 className="text-sm font-bold">播放诊断</h2><p className="mt-1 text-[11px] text-white/60">记录最近 40 条本地播放事件</p></div>
<div className="flex gap-2">
@@ -2626,7 +2629,7 @@ export function PlayerPage() {
<button type="button" onClick={() => setDiagnosticsOpen(false)} className="rounded-lg p-1.5 hover:bg-white/10" aria-label="关闭"><X size={16} /></button>
</div>
</div>
<div className="max-h-[55vh] space-y-2 overflow-y-auto p-3">
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
<div className="rounded-lg bg-white/5 px-3 py-2 text-xs text-white/70">模式:{mode === 'hls' ? `HLS / ${hlsSource}` : '直连'} · 来源:{isRemoteEmbyID(media?.id) ? '远程 Emby' : isStrmMedia(media) ? 'STRM' : '本地文件'}</div>
{diagnosticEvents.length === 0 ? <p className="py-6 text-center text-xs text-white/50">等待播放事件…</p> : [...diagnosticEvents].reverse().map((event, index) => (
<div key={`${event.at}-${index}`} className="rounded-lg bg-white/5 px-3 py-2">
+12 -5
View File
@@ -36,27 +36,34 @@ export function SettingsPage() {
}
}, [searchParams])
// refresh 只等真正需要的设置项返回。媒体库列表仅供「成人库」等下拉选项
// 使用,晚到不影响设置项渲染;此前用 Promise.all 一并等待,远程 Emby
// 挂载枚举偶尔要 10–17 秒,整页就一直空转。
const refresh = async () => {
setLoading(true)
try {
const [all, libs] = await Promise.all([
adminAPI.listSettings(),
libraryAPI.list({ includeHidden: true }).catch(() => [] as Library[]),
])
const all = await adminAPI.listSettings()
const idx: Record<string, string> = {}
for (const s of all as Setting[]) {
if (ALL_KEYS.has(s.key)) idx[s.key] = s.value
}
setValues(idx)
setLibraries(libs as Library[])
setDirty(new Set())
} finally {
setLoading(false)
}
}
const refreshLibraries = () => {
libraryAPI
.list({ includeHidden: true })
.then((libs) => setLibraries(libs as Library[]))
.catch(() => undefined)
}
useEffect(() => {
refresh().catch(() => undefined)
refreshLibraries()
}, [])
const onChange = (key: string, value: string) => {
+13
View File
@@ -0,0 +1,13 @@
import { shouldShowPlayerDiagnostics } from './playerPageModel.ts'
function check(name: string, actual: boolean, expected: boolean) {
if (actual !== expected) throw new Error(`playerDiagnostics: ${name}`)
}
check('hidden by default and during normal playback', shouldShowPlayerDiagnostics('', ''), false)
check('visible when playback fails', shouldShowPlayerDiagnostics('视频播放失败', ''), true)
check('visible when media cannot be loaded', shouldShowPlayerDiagnostics('', '无法加载该媒体'), true)
check('visible when both loading and playback fail', shouldShowPlayerDiagnostics('视频播放失败', '无法加载该媒体'), true)
check('hidden again after errors are cleared', shouldShowPlayerDiagnostics('', ''), false)
console.log('playerDiagnostics.test.ts ok')
+4
View File
@@ -3,6 +3,10 @@ import { isRemoteEmbyID } from '../utils/remoteEmby'
export type PlayerMode = 'direct' | 'hls'
export function shouldShowPlayerDiagnostics(playerError: string, loadError: string): boolean {
return Boolean(playerError || loadError)
}
const directContainers = ['mp4', 'webm', 'm4v']
const directVideoCodecs = ['h264', 'avc', 'avc1']
const directAudioCodecs = ['aac', 'mp3', 'opus']