From 41fe75e136f16578e2054d03d53c3dd18e2da01e Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:07:16 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=20Emby=20=E6=BC=94=E8=81=8C?= =?UTF-8?q?=E4=BA=BA=E5=91=98=E5=A4=B4=E5=83=8F=E4=BB=A3=E7=90=86=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=AA=92=E4=BD=93=E7=B3=BB=E5=88=97=E6=90=9C?= =?UTF-8?q?=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/emby_images.go | 56 ++++++++---- internal/handler/emby_routes.go | 7 ++ internal/handler/media.go | 33 ++++++++ internal/handler/media_test.go | 83 ++++++++++++++++++ internal/service/emby_artwork.go | 18 ++++ internal/service/emby_remote.go | 84 +++++++++++++++++- internal/service/emby_remote_ids.go | 5 ++ internal/service/emby_remote_ids_test.go | 15 ++++ internal/service/emby_remote_people_test.go | 82 ++++++++++++++++++ internal/service/media_series.go | 16 ++++ web/src/api/library.ts | 15 +++- web/src/components/LayoutHeaderSections.tsx | 94 +++++++++++++++++++-- 12 files changed, 481 insertions(+), 27 deletions(-) create mode 100644 internal/service/emby_remote_people_test.go diff --git a/internal/handler/emby_images.go b/internal/handler/emby_images.go index cd974f2..1dc9ae3 100644 --- a/internal/handler/emby_images.go +++ b/internal/handler/emby_images.go @@ -29,24 +29,44 @@ var embyPlaceholderPNG = []byte{ // /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。 func embyItemImageHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - clearEmbyImageNoStoreHeaders(c) - ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second) - defer cancel() - req := c.Request.WithContext(ctx) - id := c.Param("id") - imgType := strings.ToLower(c.Param("type")) - raw, err := svc.Emby.ImageURL(ctx, id, imgType) - if err != nil || raw == "" { - embyServePlaceholderImage(c) - return - } - if svc.ImageProxy == nil { - embyServePlaceholderImage(c) - return - } - if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil { - embyServePlaceholderImage(c) - } + embyServeImage(c, svc, c.Param("id"), c.Param("type"), false) + } +} + +// embyPersonImageHandler 兼容 Emby 官方的 /Persons/{Name}/Images/{Type}。 +// Name 可能是伪装后的远程人物 ID,也可能是电影详情 People 中的显示名称。 +func embyPersonImageHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + embyServeImage(c, svc, c.Param("name"), c.Param("type"), true) + } +} + +func embyServeImage(c *gin.Context, svc *service.Container, id, imageType string, person bool) { + clearEmbyImageNoStoreHeaders(c) + ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second) + defer cancel() + req := c.Request.WithContext(ctx) + if svc == nil || svc.Emby == nil { + embyServePlaceholderImage(c) + return + } + var raw string + var err error + if person { + raw, err = svc.Emby.PersonImageURL(ctx, id, imageType) + } else { + raw, err = svc.Emby.ImageURL(ctx, id, imageType) + } + if err != nil || raw == "" { + embyServePlaceholderImage(c) + return + } + if svc.ImageProxy == nil { + embyServePlaceholderImage(c) + return + } + if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil { + embyServePlaceholderImage(c) } } diff --git a/internal/handler/emby_routes.go b/internal/handler/emby_routes.go index 7a108d9..6118735 100644 --- a/internal/handler/emby_routes.go +++ b/internal/handler/emby_routes.go @@ -128,6 +128,13 @@ func registerEmbyPublicImageRoutes(grp *gin.RouterGroup, svc *service.Container) grp.GET("/items/:id/images/:type", embyItemImageHandler(svc)) grp.GET("/items/:id/images/:type/:index", embyItemImageHandler(svc)) grp.HEAD("/items/:id/images/:type", embyItemImageHandler(svc)) + // 官方 Emby 客户端也可能使用 /Persons/{Name}/Images/{Type} 获取演职人员头像。 + grp.GET("/Persons/:name/Images/:type", embyPersonImageHandler(svc)) + grp.GET("/Persons/:name/Images/:type/:index", embyPersonImageHandler(svc)) + grp.HEAD("/Persons/:name/Images/:type", embyPersonImageHandler(svc)) + grp.GET("/persons/:name/images/:type", embyPersonImageHandler(svc)) + grp.GET("/persons/:name/images/:type/:index", embyPersonImageHandler(svc)) + grp.HEAD("/persons/:name/images/:type", embyPersonImageHandler(svc)) // 用户头像。没有头像时返回带缓存头的 404,避免客户端反复重试。 grp.GET("/Users/:userId/Images/:type", embyUserImageHandler(svc)) grp.HEAD("/Users/:userId/Images/:type", embyUserImageHandler(svc)) diff --git a/internal/handler/media.go b/internal/handler/media.go index be67f7a..ec11266 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -563,6 +563,39 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc { return remoteItems } + if c.DefaultQuery("group_series", "0") != "0" { + localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + remoteItems := fetchRemote(50000) + all := service.GroupMediaSeriesItems(append(localItems, remoteItems...)) + + if c.Query("page") != "" || c.Query("page_size") != "" { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) + paged := paginateSlice(all, page, size) + c.JSON(http.StatusOK, gin.H{ + "items": paged, + "total": len(all), + "page": page, + "page_size": size, + }) + return + } + + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + if limit <= 0 { + limit = 50 + } + if len(all) > limit { + all = all[:limit] + } + c.JSON(http.StatusOK, gin.H{"items": all}) + return + } + if c.Query("page") != "" || c.Query("page_size") != "" { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) diff --git a/internal/handler/media_test.go b/internal/handler/media_test.go index ae52dbd..97423d3 100644 --- a/internal/handler/media_test.go +++ b/internal/handler/media_test.go @@ -447,6 +447,89 @@ func TestEmptyLibraryListsReturnEmptyArraysNotNull(t *testing.T) { } } +func TestSearchMediaGroupsSeriesBeforeLimit(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + lib := model.Library{Name: "动漫", Path: "/media/anime", Type: "anime", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + + now := time.Now() + rows := []model.Media{ + { + Base: model.Base{ID: "dbkai-ep-1", CreatedAt: now.Add(-2 * time.Minute), UpdatedAt: now.Add(-2 * time.Minute)}, + LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E01.mkv", + SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709, + }, + { + Base: model.Base{ID: "dbkai-ep-2", CreatedAt: now.Add(-time.Minute), UpdatedAt: now.Add(-time.Minute)}, + LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E02.mkv", + SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709, + }, + { + Base: model.Base{ID: "dbkai-ep-3", CreatedAt: now, UpdatedAt: now}, + LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E03.mkv", + SeasonNum: 1, EpisodeNum: 3, TMDbID: 61709, + }, + { + Base: model.Base{ID: "db-movie", CreatedAt: now.Add(-3 * time.Minute), UpdatedAt: now.Add(-3 * time.Minute)}, + LibraryID: lib.ID, Title: "龙珠超:布罗利", Path: "/media/anime/龙珠超:布罗利 (2018)/龙珠超:布罗利.mkv", + TMDbID: 503314, + }, + } + if err := db.Create(&rows).Error; err != nil { + t.Fatal(err) + } + + svc := &service.Container{ + Repo: repos, + Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos), + } + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set(middleware.CtxUserID, "user-1") + c.Set(middleware.CtxUserRole, "user") + c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=龙珠&limit=2&group_series=1", nil) + searchMediaHandler(svc)(c) + + if w.Code != http.StatusOK { + t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String()) + } + var res struct { + Items []model.Media `json:"items"` + } + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatal(err) + } + if len(res.Items) != 2 { + t.Fatalf("expected one representative per series after limit, got %d: %#v", len(res.Items), res.Items) + } + seenSeries := false + seenMovie := false + for _, item := range res.Items { + switch item.TMDbID { + case 61709: + seenSeries = true + if item.EpisodeNum != 1 { + t.Fatalf("series representative episode=%d, want first episode", item.EpisodeNum) + } + case 503314: + seenMovie = true + } + } + if !seenSeries || !seenMovie { + t.Fatalf("expected one Dragon Ball series and one movie, got %#v", res.Items) + } +} + func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) { gin.SetMode(gin.TestMode) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/service/emby_artwork.go b/internal/service/emby_artwork.go index 388284a..7dfbf1f 100644 --- a/internal/service/emby_artwork.go +++ b/internal/service/emby_artwork.go @@ -83,6 +83,24 @@ func (e *EmbyService) resolveVirtualArtwork(ctx context.Context, id, imageType s return pick(series.PosterURL, series.BackdropURL), nil } +// PersonImageURL resolves a person avatar from either a disguised remote ID +// or a person display name captured from a remote item's People field. +func (e *EmbyService) PersonImageURL(ctx context.Context, idOrName, imageType string) (string, error) { + idOrName = strings.TrimSpace(idOrName) + if idOrName == "" || e == nil { + return "", nil + } + if IsEmbyRemoteID(idOrName) { + return e.ImageURL(ctx, idOrName, imageType) + } + if e.remote != nil { + if raw, ok := e.remote.ResolveRemotePersonImageURL(ctx, idOrName, imageType); ok { + return raw, nil + } + } + return e.ImageURL(ctx, idOrName, imageType) +} + // imageInfoTypes 是 GET /Items/{Id}/Images 会报告的图片类型。只列 MeBox // 真正存储的两类:ImageURL 对 Thumb / Logo / Banner 等其余类型会回退到 // 主图,若一并列出会让客户端以为存在这些图并去请求,实际拿到的却是主图。 diff --git a/internal/service/emby_remote.go b/internal/service/emby_remote.go index 44d1678..d4a14bf 100644 --- a/internal/service/emby_remote.go +++ b/internal/service/emby_remote.go @@ -76,6 +76,14 @@ type EmbyRemoteService struct { http *http.Client stream *http.Client // 流式代理专用(视频/字幕),无整体 Timeout cache *RuntimeCacheService + + personMu sync.RWMutex + personImages map[string]embyRemotePersonImageRef +} + +type embyRemotePersonImageRef struct { + accountID string + remoteID string } // NewEmbyRemoteService 构造远程 Emby 聚合服务。 @@ -802,10 +810,12 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMou return nil, err } path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/" + url.PathEscape(remoteID) + q := url.Values{"Fields": {"Overview,Genres,ProviderIds,People,Studios,Path,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating"}} var out map[string]any - if err := r.doGet(ctx, acct, cfg, path, nil, &out); err != nil { + if err := r.doGet(ctx, acct, cfg, path, q, &out); err != nil { return nil, err } + r.rememberRemotePeople(mount, out) RewriteEmbyRemoteIDs(out, mount.ID) return out, nil } @@ -959,6 +969,78 @@ func (r *EmbyRemoteService) RemoteImageURL(ctx context.Context, acct *model.Strm "?api_key=" + url.QueryEscape(cfg.Token), nil } +// rememberRemotePeople 记录远程人物名称到远程人物 ID 的映射,供旧式 +// /Persons/{Name}/Images/{Type} 图片请求回源。客户端详情页通常先取条目详情, +// 此时 People 中的名称和 ID 已同时拿到,因此无需额外搜索远程人物。 +func (r *EmbyRemoteService) rememberRemotePeople(mount *model.EmbyMount, payload map[string]any) { + if r == nil || mount == nil || strings.TrimSpace(mount.AccountID) == "" || payload == nil { + return + } + people := remotePeopleMaps(payload["People"]) + if len(people) == 0 { + return + } + r.personMu.Lock() + defer r.personMu.Unlock() + if r.personImages == nil || len(r.personImages) > 20000 { + r.personImages = make(map[string]embyRemotePersonImageRef, 256) + } + for _, person := range people { + name := strings.TrimSpace(remoteItemString(person, "Name")) + remoteID := strings.TrimSpace(remoteItemString(person, "Id")) + if name == "" || remoteID == "" || IsEmbyRemoteID(remoteID) { + continue + } + r.personImages[strings.ToLower(name)] = embyRemotePersonImageRef{ + accountID: mount.AccountID, + remoteID: remoteID, + } + } +} + +// ResolveRemotePersonImageURL 按人物名称解析其远程头像地址。 +func (r *EmbyRemoteService) ResolveRemotePersonImageURL(ctx context.Context, name, imageType string) (string, bool) { + if r == nil { + return "", false + } + key := strings.ToLower(strings.TrimSpace(name)) + if key == "" { + return "", false + } + r.personMu.RLock() + ref, ok := r.personImages[key] + r.personMu.RUnlock() + if !ok { + return "", false + } + acct := r.AccountByID(ctx, ref.accountID) + if acct == nil { + return "", false + } + raw, err := r.RemoteImageURL(ctx, acct, ref.remoteID, imageType) + if err != nil || strings.TrimSpace(raw) == "" { + return "", false + } + return raw, true +} + +func remotePeopleMaps(value any) []map[string]any { + switch typed := value.(type) { + case []map[string]any: + return typed + case []any: + out := make([]map[string]any, 0, len(typed)) + for _, item := range typed { + if person, ok := item.(map[string]any); ok { + out = append(out, person) + } + } + return out + default: + return nil + } +} + // ─── 播放代理 ───────────────────────────────────────────────────────────────── // ProxyVideoStream 反向代理远程 Emby 视频流(保留 Range 以支持拖动)。 diff --git a/internal/service/emby_remote_ids.go b/internal/service/emby_remote_ids.go index e12ef67..222a74b 100644 --- a/internal/service/emby_remote_ids.go +++ b/internal/service/emby_remote_ids.go @@ -106,4 +106,9 @@ func rewriteEmbyRemoteIDsMap(m map[string]any, accountID string) { if items, ok := m["Items"]; ok { RewriteEmbyRemoteIDs(items, accountID) } + // 人物条目同样以 Id 回指 /Items/{Id}/Images/...。若不递归重写,客户端会 + // 把远程演员 ID 当成本地 ID,头像最终只能命中占位图。 + if people, ok := m["People"]; ok { + RewriteEmbyRemoteIDs(people, accountID) + } } diff --git a/internal/service/emby_remote_ids_test.go b/internal/service/emby_remote_ids_test.go index 3dd256a..f853d1e 100644 --- a/internal/service/emby_remote_ids_test.go +++ b/internal/service/emby_remote_ids_test.go @@ -55,6 +55,14 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) { "Items": []any{ map[string]any{"Id": "item-2", "ParentId": "folder-2"}, }, + "People": []any{ + map[string]any{ + "Id": "person-1", + "Name": "演员甲", + "Type": "Actor", + "PrimaryImageTag": "person-tag-1", + }, + }, // MediaSource 的 Id 保持原样(客户端仅作为 MediaSourceId 查询参数)。 "MediaSources": []any{ map[string]any{ @@ -90,6 +98,13 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) { if nested["Id"] != "embyremote~acct-1~item-2" { t.Fatalf("nested Id = %v", nested["Id"]) } + person := payload["People"].([]any)[0].(map[string]any) + if person["Id"] != "embyremote~acct-1~person-1" { + t.Fatalf("person Id = %v", person["Id"]) + } + if person["Name"] != "演员甲" || person["PrimaryImageTag"] != "person-tag-1" { + t.Fatalf("person display fields changed: %#v", person) + } // MediaSource.Id 与 URL 不被 ID 重写器触碰(URL 由代理模式函数改写)。 ms := payload["MediaSources"].([]any)[0].(map[string]any) diff --git a/internal/service/emby_remote_people_test.go b/internal/service/emby_remote_people_test.go new file mode 100644 index 0000000..50edb55 --- /dev/null +++ b/internal/service/emby_remote_people_test.go @@ -0,0 +1,82 @@ +package service + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" +) + +func TestRemoteItemRequestsPeopleAndRewritesPersonIDs(t *testing.T) { + var requestedFields string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/Users/user-1/Items/item-1") { + http.NotFound(w, r) + return + } + requestedFields = r.URL.Query().Get("Fields") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "Id": "item-1", + "Name": "测试电影", + "People": []map[string]any{ + { + "Id": "person-1", + "Name": "演员甲", + "Type": "Actor", + "PrimaryImageTag": "person-tag-1", + }, + }, + }) + })) + defer server.Close() + + db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}) + repos := repository.New(db) + svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop())) + rawConfig, _ := json.Marshal(map[string]string{ + "url": server.URL, + "token": "fake-token", + "remote_user_id": "user-1", + }) + acct := &model.StrmAccount{ + Base: model.Base{ID: "acct-people"}, + 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-people"}, AccountID: acct.ID} + + out, err := svc.RemoteItem(t.Context(), mount, acct, "item-1") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(requestedFields, "People") { + t.Fatalf("RemoteItem Fields = %q, want People", requestedFields) + } + people, ok := out["People"].([]any) + if !ok || len(people) != 1 { + t.Fatalf("People = %#v, want one person", out["People"]) + } + person := people[0].(map[string]any) + if got := person["Id"]; got != EncodeEmbyRemoteID("mount-people", "person-1") { + t.Fatalf("person Id = %v, want encoded remote id", got) + } + if person["Name"] != "演员甲" || person["PrimaryImageTag"] != "person-tag-1" { + t.Fatalf("person display fields changed: %#v", person) + } + raw, ok := svc.ResolveRemotePersonImageURL(t.Context(), "演员甲", "Primary") + if !ok || !strings.Contains(raw, "/Items/person-1/Images/primary") { + t.Fatalf("resolved person image URL = %q, ok=%v", raw, ok) + } +} diff --git a/internal/service/media_series.go b/internal/service/media_series.go index 75997b3..c29ee3a 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -327,6 +327,22 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard { return cards } +// GroupMediaSeriesItems folds episode-level rows into one representative media +// row per series. It is intended for search surfaces where applying a small +// limit before series grouping would otherwise return several episodes from +// the same show. +func GroupMediaSeriesItems(items []model.Media) []model.Media { + cards := groupMediaSeriesCards(items) + if len(cards) == 0 { + return []model.Media{} + } + out := make([]model.Media, 0, len(cards)) + for _, card := range cards { + out = append(out, card.Rep) + } + return out +} + func betterSeriesRepresentative(candidate, current model.Media) bool { // A theatrical feature can have local poster.jpg/background.jpg files and // therefore a higher artwork score than its TV episodes. Keep the TV row as diff --git a/web/src/api/library.ts b/web/src/api/library.ts index ed113c4..8280d26 100644 --- a/web/src/api/library.ts +++ b/web/src/api/library.ts @@ -193,10 +193,18 @@ export const mediaAPI = { recent: (limit = 24) => api.get('/media/recent', { params: { limit } }).then((r) => r.data), - search: (q: string, limit = 50) => - api.get('/media', { params: { q, limit } }).then((r) => r.data), + search: (q: string, limit = 50, options?: { groupSeries?: boolean }) => + api + .get('/media', { + params: { + q, + limit, + ...(options?.groupSeries ? { group_series: 1 } : {}), + }, + }) + .then((r) => r.data), - searchPage: (q: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean }) => + searchPage: (q: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean; groupSeries?: boolean }) => api .get('/media', { params: { @@ -204,6 +212,7 @@ export const mediaAPI = { page, page_size: pageSize, group_versions: options?.groupVersions === false ? 0 : undefined, + ...(options?.groupSeries ? { group_series: 1 } : {}), }, timeout: LONG_REQUEST_TIMEOUT, }) diff --git a/web/src/components/LayoutHeaderSections.tsx b/web/src/components/LayoutHeaderSections.tsx index deb6f4d..2b7601e 100644 --- a/web/src/components/LayoutHeaderSections.tsx +++ b/web/src/components/LayoutHeaderSections.tsx @@ -108,15 +108,23 @@ export function LayoutHeader({ ) } +const SEARCH_PAGE_SIZE = 8 + function LayoutHeaderSearch() { const [query, setQuery] = useState('') const [isOpen, setIsOpen] = useState(false) const [loading, setLoading] = useState(false) + const [loadingMore, setLoadingMore] = useState(false) const [results, setResults] = useState([]) + const [hasMore, setHasMore] = useState(false) const containerRef = useRef(null) const isOpenRef = useRef(false) // 递增序号守卫:快速连续输入时丢弃过期响应 const searchSeqRef = useRef(0) + const pageRef = useRef(0) + const loadingMoreRef = useRef(false) + const resultsRef = useRef([]) + const hasMoreRef = useRef(false) const navigate = useNavigate() const setSearchOpen = (open: boolean) => { @@ -128,23 +136,43 @@ function LayoutHeaderSearch() { const trimmed = query.trim() if (!trimmed) { searchSeqRef.current += 1 + pageRef.current = 0 + loadingMoreRef.current = false + resultsRef.current = [] + hasMoreRef.current = false setResults([]) setLoading(false) + setLoadingMore(false) + setHasMore(false) isOpenRef.current = false setIsOpen(false) return } const seq = ++searchSeqRef.current + pageRef.current = 0 + loadingMoreRef.current = false + resultsRef.current = [] + hasMoreRef.current = false + setResults([]) + setHasMore(false) setLoading(true) + setLoadingMore(false) const timer = setTimeout(async () => { try { - const res = await mediaAPI.search(trimmed, 8) + const res = await mediaAPI.searchPage(trimmed, 1, SEARCH_PAGE_SIZE, { groupSeries: true }) if (seq !== searchSeqRef.current) return - setResults(res.items || []) + const items = res.items || [] + const total = res.total ?? items.length + const more = items.length < total + pageRef.current = 1 + resultsRef.current = items + hasMoreRef.current = more + setResults(items) + setHasMore(more) if (isOpenRef.current) setIsOpen(true) } catch { - // 请求失败时保留旧结果,避免网络抖动清空下拉 + // 请求失败时保持空结果,用户继续输入或滚动时会重新请求 } finally { if (seq === searchSeqRef.current) setLoading(false) } @@ -164,6 +192,40 @@ function LayoutHeaderSearch() { return () => document.removeEventListener('mousedown', handleClickOutside) }, []) + const loadMore = async () => { + const trimmed = query.trim() + if (!trimmed || loading || loadingMoreRef.current || !hasMoreRef.current) return + + const seq = searchSeqRef.current + const nextPage = pageRef.current + 1 + loadingMoreRef.current = true + setLoadingMore(true) + + try { + const res = await mediaAPI.searchPage(trimmed, nextPage, SEARCH_PAGE_SIZE, { groupSeries: true }) + if (seq !== searchSeqRef.current) return + + const incoming = res.items || [] + const currentResults = resultsRef.current + const known = new Set(currentResults.map((item) => item.id)) + const nextResults = [...currentResults, ...incoming.filter((item) => !known.has(item.id))] + const total = res.total ?? nextResults.length + const more = nextPage * SEARCH_PAGE_SIZE < total + pageRef.current = nextPage + resultsRef.current = nextResults + hasMoreRef.current = more + setResults(nextResults) + setHasMore(more) + } catch { + // 请求失败时保留当前结果,继续滚动可重试 + } finally { + if (seq === searchSeqRef.current) { + loadingMoreRef.current = false + setLoadingMore(false) + } + } + } + const handleSelect = (item: Media) => { setSearchOpen(false) setQuery('') @@ -225,9 +287,20 @@ function LayoutHeaderSearch() { animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 6 }} transition={{ duration: 0.15 }} - className="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-2xl z-50 backdrop-blur-xl" + onScroll={(e) => { + const target = e.currentTarget + if (target.scrollHeight - target.scrollTop - target.clientHeight < 80) { + void loadMore() + } + }} + className="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto overscroll-contain rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-2xl z-50 backdrop-blur-xl" > - {results.length === 0 && !loading ? ( + {results.length === 0 && loading ? ( +
+ + 正在搜索… +
+ ) : results.length === 0 ? (
未搜索到与 “{query}” 相关的媒体内容
@@ -282,6 +355,17 @@ function LayoutHeaderSearch() { ))} + {loadingMore && ( +
+ + 正在加载更多… +
+ )} + {!loadingMore && !hasMore && results.length > 0 && ( +
+ 已显示全部 {results.length} 条结果 +
+ )} )}