diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go index d3e581d..13e894c 100644 --- a/internal/service/emby_items_detail.go +++ b/internal/service/emby_items_detail.go @@ -218,7 +218,7 @@ func (e *EmbyService) loadLatestItems(ctx context.Context, userID, parentID stri if mount == nil || acct == nil || !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) { return embyLatestCacheValue{Items: []map[string]any{}}, nil } - out, err := e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit) + out, err := e.remote.RemoteLatestForDisplay(ctx, mount, acct, remoteParent, limit) if err != nil { return embyLatestCacheValue{}, err } diff --git a/internal/service/emby_remote.go b/internal/service/emby_remote.go index 019d64b..27e4af7 100644 --- a/internal/service/emby_remote.go +++ b/internal/service/emby_remote.go @@ -860,6 +860,162 @@ func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyM return out, nil } +// RemoteLatestForDisplay 返回可直接展示的最新媒体条目。剧集库优先取 Series; +// 远程 Latest 只返回 Episode 时,按 SeriesId 归并,避免调用方拿到单集 ID +// 后无法在 Series 卡片列表中找到对应条目。 +func (r *EmbyRemoteService) RemoteLatestForDisplay(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]map[string]any, error) { + if remoteCollectionLooksEpisodic(mount.CollectionType) { + return r.RemoteLatestSeries(ctx, mount, acct, remoteViewID, limit) + } + items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit) + if err != nil { + return nil, err + } + if remoteItemsContainEpisodes(items) { + return remoteSeriesItemsFromLatest(mount, items, limit), nil + } + return items, nil +} + +// RemoteLatestSeries 拉取剧集库最近更新的 Series。部分 Emby 服务不支持 +// DateLastContentAdded 或过滤 Series,此时回退到 Latest 并把 Episode 归并到 +// 对应 Series。 +func (r *EmbyRemoteService) RemoteLatestSeries(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]map[string]any, error) { + if limit <= 0 || limit > 100 { + limit = 20 + } + cfg, err := r.remoteConfigWithToken(ctx, acct) + if err != nil { + return nil, err + } + q := url.Values{} + q.Set("ParentId", remoteViewID) + q.Set("IncludeItemTypes", "Series") + q.Set("Recursive", "false") + q.Set("SortBy", "DateLastContentAdded") + q.Set("SortOrder", "Descending") + q.Set("Limit", strconv.Itoa(limit)) + q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating") + var body struct { + Items []map[string]any `json:"Items"` + } + if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err == nil && len(body.Items) > 0 { + RewriteEmbyRemoteIDs(body.Items, mount.ID) + return body.Items, nil + } + + items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit) + if err != nil { + return nil, err + } + return remoteSeriesItemsFromLatest(mount, items, limit), nil +} + +func remoteCollectionLooksEpisodic(collectionType string) bool { + switch strings.ToLower(strings.TrimSpace(collectionType)) { + case "tvshows", "tv": + return true + default: + return false + } +} + +func remoteItemsContainEpisodes(items []map[string]any) bool { + for _, item := range items { + if strings.EqualFold(strings.TrimSpace(remoteItemString(item, "Type")), "Episode") { + return true + } + } + return false +} + +func remoteSeriesItemsFromLatest(mount *model.EmbyMount, items []map[string]any, limit int) []map[string]any { + if limit <= 0 || limit > 100 { + limit = 20 + } + out := make([]map[string]any, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + if len(out) >= limit { + break + } + if !strings.EqualFold(strings.TrimSpace(remoteItemString(item, "Type")), "Episode") { + id := strings.TrimSpace(remoteItemString(item, "Id")) + if id == "" { + continue + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + out = append(out, item) + continue + } + + seriesID := remoteItemSeriesID(item, mount) + if seriesID == "" { + continue + } + if _, exists := seen[seriesID]; exists { + continue + } + seen[seriesID] = struct{}{} + out = append(out, remoteSeriesPayloadFromEpisode(mount, item, seriesID)) + } + return out +} + +func remoteItemSeriesID(item map[string]any, mount *model.EmbyMount) string { + seriesID := strings.TrimSpace(remoteItemString(item, "SeriesId")) + if seriesID == "" { + return "" + } + if _, rawID, ok := DecodeEmbyRemoteID(seriesID); ok { + if mount == nil { + return rawID + } + return EncodeEmbyRemoteID(mount.ID, rawID) + } + if mount == nil { + return seriesID + } + return EncodeEmbyRemoteID(mount.ID, seriesID) +} + +func remoteSeriesPayloadFromEpisode(mount *model.EmbyMount, episode map[string]any, seriesID string) map[string]any { + name := strings.TrimSpace(remoteItemString(episode, "SeriesName")) + if name == "" { + name = strings.TrimSpace(remoteItemString(episode, "Name")) + } + out := map[string]any{ + "Id": seriesID, + "Name": name, + "Type": "Series", + } + if mount != nil && strings.TrimSpace(mount.RemoteViewID) != "" { + out["ParentId"] = EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID) + } + if tag := strings.TrimSpace(remoteItemString(episode, "SeriesPrimaryImageTag")); tag != "" { + out["ImageTags"] = map[string]any{"Primary": tag} + } + year := remoteItemInt(episode, "SeriesProductionYear") + if year == 0 { + year = remoteItemInt(episode, "SeriesYear") + } + if year == 0 { + year = remoteItemInt(episode, "ProductionYear") + } + if year > 0 { + out["ProductionYear"] = year + } + for _, key := range []string{"Genres", "ProviderIds", "DateLastMediaAdded", "CommunityRating", "CriticRating", "OfficialRating"} { + if value, ok := episode[key]; ok { + out[key] = value + } + } + return out +} + // RemotePlaybackInfo 拉取远程 PlaybackInfo,并按挂载的 proxy_play 配置重写 // 播放 URL:不代理=指向远程绝对地址(播放字节不过 MeBox);代理=指向 MeBox // 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。 diff --git a/internal/service/emby_remote_web.go b/internal/service/emby_remote_web.go index ae3f741..e6f4ef7 100644 --- a/internal/service/emby_remote_web.go +++ b/internal/service/emby_remote_web.go @@ -180,31 +180,31 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok { seriesID = rid } - rating := remoteItemFloat(item, "CommunityRating") - if rating == 0 { - rating = remoteItemFloat(item, "CriticRating") - } - year := remoteItemInt(item, "ProductionYear") - if year == 0 { - year = remoteItemInt(item, "Year") - } - if year == 0 { - year = remoteItemInt(item, "SeriesProductionYear") - } - if year == 0 { - year = remoteItemInt(item, "SeriesYear") - } - media := model.Media{ - Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)}, - Title: remoteItemString(item, "Name"), - OriginalName: remoteItemString(item, "OriginalTitle"), - Overview: remoteItemString(item, "Overview"), - Year: year, - Rating: float32(rating), - Path: remoteItemString(item, "Path"), - Genres: remoteItemGenres(item), - ScrapeStatus: "done", - } + rating := remoteItemFloat(item, "CommunityRating") + if rating == 0 { + rating = remoteItemFloat(item, "CriticRating") + } + year := remoteItemInt(item, "ProductionYear") + if year == 0 { + year = remoteItemInt(item, "Year") + } + if year == 0 { + year = remoteItemInt(item, "SeriesProductionYear") + } + if year == 0 { + year = remoteItemInt(item, "SeriesYear") + } + media := model.Media{ + Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)}, + Title: remoteItemString(item, "Name"), + OriginalName: remoteItemString(item, "OriginalTitle"), + Overview: remoteItemString(item, "Overview"), + Year: year, + Rating: float32(rating), + Path: remoteItemString(item, "Path"), + Genres: remoteItemGenres(item), + ScrapeStatus: "done", + } if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateCreated")); ok { media.CreatedAt = date media.UpdatedAt = date @@ -509,6 +509,11 @@ func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.E return items, total, nil } +// remoteSeriesPageSize 是远程剧集分页拉取的每页条数:Fields 带 Overview/Path +// 等重字段,单页 1000 条时载荷会超过 doGet 的 8MB 截断上限,JSON 被静默截断 +// 直接解析失败,因此按 200 条翻页拉全量。 +const remoteSeriesPageSize = 200 + // RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。 // // 远程 Emby 的 Series DTO 不会返回 DateLastMediaAdded 字段(即使请求 Fields @@ -532,16 +537,14 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model. q.Set("Recursive", "false") q.Set("SortBy", "DateLastContentAdded") q.Set("SortOrder", "Descending") - // 每页 200:Fields 带全量重字段(Overview/MediaStreams 等)时单页 1000 - // 条的载荷会超过 doGet 的 8MB 截断上限,JSON 被静默截断直接解析失败。 - q.Set("Limit", "200") + q.Set("Limit", strconv.Itoa(remoteSeriesPageSize)) q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating") var body struct { Items []map[string]any `json:"Items"` TotalRecordCount int64 `json:"TotalRecordCount"` } cards := make([]SeriesCard, 0) - for startIndex := 0; ; startIndex += 200 { + for startIndex := 0; ; startIndex += remoteSeriesPageSize { q.Set("StartIndex", strconv.Itoa(startIndex)) body.Items = nil if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil { @@ -565,9 +568,16 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model. if date, ok := parseEmbyRemoteDate(remoteItemString(it, "DateLastMediaAdded")); ok { lastAdded = &date } - cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded}) + cards = append(cards, SeriesCard{ + Key: m.ID, + Rep: m, + LinkMedia: m, + Count: count, + IsSeries: true, + LastAddedAt: lastAdded, + }) } - if int64(len(cards)) >= body.TotalRecordCount || len(body.Items) < 1000 { + if int64(len(cards)) >= body.TotalRecordCount || len(body.Items) < remoteSeriesPageSize { break } } @@ -589,52 +599,14 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model. return cached, nil } - // 剧集类媒体库:直接拉取最新入库/更新的 Series 剧集本身(按上次添加集日期倒序)。 - // 避免 Emby /Items/Latest 默认返回无年份/无系列海报的单集(Episode)。 - if mount != nil && (mount.CollectionType == "tvshows" || mount.CollectionType == "tv") { - q := url.Values{} - q.Set("ParentId", remoteViewID) - q.Set("IncludeItemTypes", "Series") - q.Set("Recursive", "false") - q.Set("SortBy", "DateLastContentAdded") - q.Set("SortOrder", "Descending") - q.Set("Limit", strconv.Itoa(limit)) - q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating") - var body struct { - Items []map[string]any `json:"Items"` - } - if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err == nil && len(body.Items) > 0 { - cards := make([]SeriesCard, 0, len(body.Items)) - for _, it := range body.Items { - RewriteEmbyRemoteIDs(it, mount.ID) - m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it) - count := remoteItemInt(it, "RecursiveItemCount") - if count == 0 { - count = remoteItemInt(it, "ChildCount") - } - if count == 0 { - count = 1 - } - var lastAdded *time.Time - if date, ok := parseEmbyRemoteDate(remoteItemString(it, "DateLastMediaAdded")); ok { - lastAdded = &date - } - cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded}) - } - if r.cache != nil { - r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL()) - } - return cards, nil - } - } - - items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit) + items, err := r.RemoteLatestForDisplay(ctx, mount, acct, remoteViewID, limit) if err != nil { return nil, err } cards := make([]SeriesCard, 0, len(items)) for _, it := range items { m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it) + isSeries := strings.EqualFold(strings.TrimSpace(remoteItemString(it, "Type")), "Series") var lastAdded *time.Time if !m.UpdatedAt.IsZero() { t := m.UpdatedAt @@ -647,7 +619,17 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model. if count == 0 { count = remoteItemInt(it, "ChildCount") } - cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: count, LastAddedAt: lastAdded}) + if count == 0 { + count = 1 + } + cards = append(cards, SeriesCard{ + Key: m.ID, + Rep: m, + LinkMedia: m, + Count: count, + IsSeries: isSeries, + LastAddedAt: lastAdded, + }) } if r.cache != nil { r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL()) diff --git a/internal/service/emby_remote_web_test.go b/internal/service/emby_remote_web_test.go index ec3886e..a2857e4 100644 --- a/internal/service/emby_remote_web_test.go +++ b/internal/service/emby_remote_web_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strconv" "strings" "sync/atomic" "testing" @@ -465,6 +466,9 @@ func TestRemoteLatestCardsTvShowsYearAndPoster(t *testing.T) { if cards[0].Count != 12 { t.Fatalf("expected Count 12, got %d", cards[0].Count) } + if !cards[0].IsSeries { + t.Fatalf("expected remote latest card to be marked as series") + } if cards[0].Rep.PosterURL == "" { t.Fatalf("expected PosterURL not empty") } @@ -476,6 +480,348 @@ func TestRemoteLatestCardsTvShowsYearAndPoster(t *testing.T) { } } +func TestRemoteLatestCardsTvShowsGroupsEpisodeFallbackBySeries(t *testing.T) { + var seriesQueryCalled atomic.Bool + var latestCalled atomic.Bool + var requestedSeriesType atomic.Value + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch strings.TrimPrefix(r.URL.Path, "/emby") { + case "/Users/remote-user/Items": + seriesQueryCalled.Store(true) + requestedSeriesType.Store(r.URL.Query().Get("IncludeItemTypes")) + _ = json.NewEncoder(w).Encode(map[string]any{ + "Items": []map[string]any{}, + "TotalRecordCount": 0, + }) + case "/Users/remote-user/Items/Latest": + latestCalled.Store(true) + _ = json.NewEncoder(w).Encode([]map[string]any{ + { + "Id": "episode-2", + "Name": "第二集", + "Type": "Episode", + "SeriesId": "series-b", + "SeriesName": "剧集 B", + "SeriesProductionYear": 2025, + "SeriesPrimaryImageTag": "poster-b", + }, + { + "Id": "episode-1", + "Name": "第一集", + "Type": "Episode", + "SeriesId": "series-a", + "SeriesName": "剧集 A", + "SeriesProductionYear": 2024, + }, + { + "Id": "episode-1b", + "Name": "第一集下", + "Type": "Episode", + "SeriesId": "series-a", + "SeriesName": "剧集 A", + }, + }) + default: + http.NotFound(w, r) + } + })) + 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": "remote-user", + }) + acct := &model.StrmAccount{ + Base: model.Base{ID: "acct-tv-fallback"}, + Name: "tv-emby", + Provider: model.StrmProviderEmbyRemote, + Config: string(rawConfig), + Enabled: true, + } + if err := repos.StrmAccount.Create(t.Context(), acct); err != nil { + t.Fatalf("create account: %v", err) + } + mount := &model.EmbyMount{ + Base: model.Base{ID: "mount-tv-fallback"}, + AccountID: acct.ID, + RemoteViewID: "view-tv", + RemoteViewName: "剧集库", + CollectionType: "tvshows", + Enabled: true, + } + if err := repos.EmbyMount.Create(t.Context(), mount); err != nil { + t.Fatalf("create mount: %v", err) + } + + cards, err := svc.RemoteLatestCards(t.Context(), mount, acct, mount.RemoteViewID, 10) + if err != nil { + t.Fatalf("RemoteLatestCards failed: %v", err) + } + if !seriesQueryCalled.Load() || !latestCalled.Load() { + t.Fatalf("expected both Series query and Latest fallback, series=%v latest=%v", seriesQueryCalled.Load(), latestCalled.Load()) + } + if got, _ := requestedSeriesType.Load().(string); got != "Series" { + t.Fatalf("series query IncludeItemTypes = %q, want Series", got) + } + if len(cards) != 2 { + t.Fatalf("expected 2 deduplicated series cards, got %d: %#v", len(cards), cards) + } + wantKeys := []string{ + EncodeEmbyRemoteID(mount.ID, "series-b"), + EncodeEmbyRemoteID(mount.ID, "series-a"), + } + for i, want := range wantKeys { + if cards[i].Key != want { + t.Fatalf("card[%d].Key = %q, want %q", i, cards[i].Key, want) + } + if !cards[i].IsSeries { + t.Fatalf("card[%d] should be marked as series", i) + } + } +} + +func TestEmbyLatestItemsRemoteTvShowsUseSeriesIDs(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch strings.TrimPrefix(r.URL.Path, "/emby") { + case "/Users/remote-user/Items": + _ = json.NewEncoder(w).Encode(map[string]any{ + "Items": []map[string]any{}, + "TotalRecordCount": 0, + }) + case "/Users/remote-user/Items/Latest": + _ = json.NewEncoder(w).Encode([]map[string]any{ + { + "Id": "episode-10", + "Name": "第十集", + "Type": "Episode", + "SeriesId": "series-10", + "SeriesName": "远程剧集", + }, + }) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + db := newServiceTestDB(t, + &model.Setting{}, + &model.User{}, + &model.Favorite{}, + &model.PlaybackHistory{}, + &model.StrmAccount{}, + &model.EmbyMount{}, + ) + repos := repository.New(db) + remoteSvc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop())) + svc := NewEmbyService(&config.Config{}, zap.NewNop(), repos).SetEmbyRemote(remoteSvc) + + rawConfig, _ := json.Marshal(map[string]string{ + "url": server.URL, + "token": "fake-token", + "remote_user_id": "remote-user", + }) + acct := &model.StrmAccount{ + Base: model.Base{ID: "acct-emby-latest"}, + Name: "tv-emby", + Provider: model.StrmProviderEmbyRemote, + Config: string(rawConfig), + Enabled: true, + } + if err := repos.StrmAccount.Create(t.Context(), acct); err != nil { + t.Fatalf("create account: %v", err) + } + mount := &model.EmbyMount{ + Base: model.Base{ID: "mount-emby-latest"}, + AccountID: acct.ID, + RemoteViewID: "view-tv", + RemoteViewName: "剧集库", + CollectionType: "tvshows", + Enabled: true, + } + if err := repos.EmbyMount.Create(t.Context(), mount); err != nil { + t.Fatalf("create mount: %v", err) + } + + items, err := svc.LatestItems(t.Context(), "user-1", EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID), 10) + if err != nil { + t.Fatalf("LatestItems failed: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 series item, got %d: %#v", len(items), items) + } + if items[0]["Type"] != "Series" { + t.Fatalf("latest item Type = %#v, want Series", items[0]["Type"]) + } + wantID := EncodeEmbyRemoteID(mount.ID, "series-10") + if items[0]["Id"] != wantID { + t.Fatalf("latest item Id = %#v, want %q", items[0]["Id"], wantID) + } +} + +// 远程剧集库分页拉全量:超过一页时必须继续翻页,否则第 201 条之后的剧集 +// 既不会出现在媒体库列表里,首页深链过来的 ?series= 也无从命中。 +func TestRemoteSeriesCardsFetchAllPages(t *testing.T) { + const totalSeries = remoteSeriesPageSize*2 + 50 + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + query := r.URL.Query() + startIndex, _ := strconv.Atoi(query.Get("StartIndex")) + limit, _ := strconv.Atoi(query.Get("Limit")) + requests.Add(1) + items := make([]map[string]any, 0, limit) + for i := startIndex; i < totalSeries && len(items) < limit; i++ { + items = append(items, map[string]any{ + "Id": "series-" + strconv.Itoa(i), + "Name": "剧集 " + strconv.Itoa(i), + "Type": "Series", + "RecursiveItemCount": 12, + }) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "Items": items, + "TotalRecordCount": totalSeries, + }) + })) + 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": "remote-user", + }) + acct := &model.StrmAccount{ + Base: model.Base{ID: "acct-series-pages"}, + Name: "tv-emby", + Provider: model.StrmProviderEmbyRemote, + Config: string(rawConfig), + Enabled: true, + } + if err := repos.StrmAccount.Create(t.Context(), acct); err != nil { + t.Fatalf("create account: %v", err) + } + mount := &model.EmbyMount{ + Base: model.Base{ID: "mount-series-pages"}, + AccountID: acct.ID, + RemoteViewID: "view-tv", + RemoteViewName: "电视剧", + CollectionType: "tvshows", + Enabled: true, + } + if err := repos.EmbyMount.Create(t.Context(), mount); err != nil { + t.Fatalf("create mount: %v", err) + } + + cards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, mount.RemoteViewID) + if err != nil { + t.Fatalf("RemoteSeriesCards failed: %v", err) + } + if len(cards) != totalSeries { + t.Fatalf("expected all %d series, got %d", totalSeries, len(cards)) + } + if got := requests.Load(); got != 3 { + t.Fatalf("expected 3 paged requests, got %d", got) + } + lastKey := EncodeEmbyRemoteID(mount.ID, "series-"+strconv.Itoa(totalSeries-1)) + if cards[len(cards)-1].Key != lastKey { + t.Fatalf("last card key = %q, want %q", cards[len(cards)-1].Key, lastKey) + } +} + +// 首页「最新条目」卡片 key 必须与媒体库剧集列表的 key 一致,否则点击后 +// 媒体库页找不到目标剧集,只能退回整库列表。 +func TestRemoteLatestCardsKeyMatchesSeriesList(t *testing.T) { + var seriesQueryCalls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.TrimPrefix(r.URL.Path, "/emby") != "/Users/remote-user/Items" { + http.NotFound(w, r) + return + } + seriesQueryCalls.Add(1) + _ = json.NewEncoder(w).Encode(map[string]any{ + "Items": []map[string]any{ + { + "Id": "series-latest", + "Name": "刚刚更新", + "Type": "Series", + "RecursiveItemCount": 24, + }, + }, + "TotalRecordCount": 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": "remote-user", + }) + acct := &model.StrmAccount{ + Base: model.Base{ID: "acct-latest-key"}, + Name: "tv-emby", + Provider: model.StrmProviderEmbyRemote, + Config: string(rawConfig), + Enabled: true, + } + if err := repos.StrmAccount.Create(t.Context(), acct); err != nil { + t.Fatalf("create account: %v", err) + } + mount := &model.EmbyMount{ + Base: model.Base{ID: "mount-latest-key"}, + AccountID: acct.ID, + RemoteViewID: "view-tv", + RemoteViewName: "电视剧", + CollectionType: "tvshows", + Enabled: true, + } + if err := repos.EmbyMount.Create(t.Context(), mount); err != nil { + t.Fatalf("create mount: %v", err) + } + + latestCards, err := svc.RemoteLatestCards(t.Context(), mount, acct, mount.RemoteViewID, 10) + if err != nil { + t.Fatalf("RemoteLatestCards failed: %v", err) + } + if len(latestCards) != 1 || !latestCards[0].IsSeries { + t.Fatalf("expected 1 series latest card, got %#v", latestCards) + } + seriesCards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, mount.RemoteViewID) + if err != nil { + t.Fatalf("RemoteSeriesCards failed: %v", err) + } + if seriesQueryCalls.Load() < 2 { + t.Fatalf("expected both latest and series list queries, got %d", seriesQueryCalls.Load()) + } + found := false + for _, card := range seriesCards { + if card.Key == latestCards[0].Key { + found = true + break + } + } + if !found { + t.Fatalf("latest card key %q not present in series list keys %#v", latestCards[0].Key, seriesCards) + } +} + func TestRemoteLatestFields(t *testing.T) { var requestedFields string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/service/media_series.go b/internal/service/media_series.go index f0ff509..4d29129 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -33,6 +33,7 @@ type SeriesCard struct { Rep model.Media `json:"rep"` LinkMedia model.Media `json:"linkMedia"` Count int `json:"count"` + IsSeries bool `json:"is_series,omitempty"` LastAddedAt *time.Time `json:"last_added_at,omitempty"` } @@ -45,6 +46,7 @@ type SeriesCardView struct { Rep model.Media `json:"rep"` LinkLibraryID string `json:"linkLibraryId,omitempty"` Count int `json:"count"` + IsSeries bool `json:"is_series,omitempty"` LastAddedAt *time.Time `json:"last_added_at,omitempty"` } @@ -59,6 +61,7 @@ func NewSeriesCardViews(cards []SeriesCard) []SeriesCardView { Rep: card.Rep, LinkLibraryID: mediaTargetLibraryID(card.LinkMedia), Count: card.Count, + IsSeries: card.IsSeries, LastAddedAt: card.LastAddedAt, } } diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index 48a5573..6bc4ce6 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -124,6 +124,7 @@ export function LibraryPage() { }) const { + resolvingSeries, selectedEpisodes, visibleEpisodes, selectedSeriesEpisodes, @@ -132,6 +133,7 @@ export function LibraryPage() { handleSeriesClick, clearSelectedSeries, } = useLibrarySeriesSelection({ + libraryID: id, items, seriesEpisodeItems, isSeriesLibrary, @@ -209,7 +211,9 @@ export function LibraryPage() { return <>{actions}> }, [canFavorite, favouriteBusyID, handleToggleFavourite, isFavourite, movieActions]) - if (loading) { + // resolvingSeries:深链目标剧集不在已加载的一页卡片里,正在按 key + // 单独解析。此时先显示加载态,避免先渲染整个媒体库列表再跳走。 + if (loading || resolvingSeries) { return (