mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-10 23:36:37 +08:00
bug处理
This commit is contained in:
@@ -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) {
|
if mount == nil || acct == nil || !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
|
||||||
return embyLatestCacheValue{Items: []map[string]any{}}, nil
|
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 {
|
if err != nil {
|
||||||
return embyLatestCacheValue{}, err
|
return embyLatestCacheValue{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -860,6 +860,162 @@ func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyM
|
|||||||
return out, nil
|
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 配置重写
|
// RemotePlaybackInfo 拉取远程 PlaybackInfo,并按挂载的 proxy_play 配置重写
|
||||||
// 播放 URL:不代理=指向远程绝对地址(播放字节不过 MeBox);代理=指向 MeBox
|
// 播放 URL:不代理=指向远程绝对地址(播放字节不过 MeBox);代理=指向 MeBox
|
||||||
// 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。
|
// 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。
|
||||||
|
|||||||
@@ -180,31 +180,31 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod
|
|||||||
if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok {
|
if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok {
|
||||||
seriesID = rid
|
seriesID = rid
|
||||||
}
|
}
|
||||||
rating := remoteItemFloat(item, "CommunityRating")
|
rating := remoteItemFloat(item, "CommunityRating")
|
||||||
if rating == 0 {
|
if rating == 0 {
|
||||||
rating = remoteItemFloat(item, "CriticRating")
|
rating = remoteItemFloat(item, "CriticRating")
|
||||||
}
|
}
|
||||||
year := remoteItemInt(item, "ProductionYear")
|
year := remoteItemInt(item, "ProductionYear")
|
||||||
if year == 0 {
|
if year == 0 {
|
||||||
year = remoteItemInt(item, "Year")
|
year = remoteItemInt(item, "Year")
|
||||||
}
|
}
|
||||||
if year == 0 {
|
if year == 0 {
|
||||||
year = remoteItemInt(item, "SeriesProductionYear")
|
year = remoteItemInt(item, "SeriesProductionYear")
|
||||||
}
|
}
|
||||||
if year == 0 {
|
if year == 0 {
|
||||||
year = remoteItemInt(item, "SeriesYear")
|
year = remoteItemInt(item, "SeriesYear")
|
||||||
}
|
}
|
||||||
media := model.Media{
|
media := model.Media{
|
||||||
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
|
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
|
||||||
Title: remoteItemString(item, "Name"),
|
Title: remoteItemString(item, "Name"),
|
||||||
OriginalName: remoteItemString(item, "OriginalTitle"),
|
OriginalName: remoteItemString(item, "OriginalTitle"),
|
||||||
Overview: remoteItemString(item, "Overview"),
|
Overview: remoteItemString(item, "Overview"),
|
||||||
Year: year,
|
Year: year,
|
||||||
Rating: float32(rating),
|
Rating: float32(rating),
|
||||||
Path: remoteItemString(item, "Path"),
|
Path: remoteItemString(item, "Path"),
|
||||||
Genres: remoteItemGenres(item),
|
Genres: remoteItemGenres(item),
|
||||||
ScrapeStatus: "done",
|
ScrapeStatus: "done",
|
||||||
}
|
}
|
||||||
if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateCreated")); ok {
|
if date, ok := parseEmbyRemoteDate(remoteItemString(item, "DateCreated")); ok {
|
||||||
media.CreatedAt = date
|
media.CreatedAt = date
|
||||||
media.UpdatedAt = date
|
media.UpdatedAt = date
|
||||||
@@ -509,6 +509,11 @@ func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.E
|
|||||||
return items, total, nil
|
return items, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// remoteSeriesPageSize 是远程剧集分页拉取的每页条数:Fields 带 Overview/Path
|
||||||
|
// 等重字段,单页 1000 条时载荷会超过 doGet 的 8MB 截断上限,JSON 被静默截断
|
||||||
|
// 直接解析失败,因此按 200 条翻页拉全量。
|
||||||
|
const remoteSeriesPageSize = 200
|
||||||
|
|
||||||
// RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。
|
// RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。
|
||||||
//
|
//
|
||||||
// 远程 Emby 的 Series DTO 不会返回 DateLastMediaAdded 字段(即使请求 Fields
|
// 远程 Emby 的 Series DTO 不会返回 DateLastMediaAdded 字段(即使请求 Fields
|
||||||
@@ -532,16 +537,14 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.
|
|||||||
q.Set("Recursive", "false")
|
q.Set("Recursive", "false")
|
||||||
q.Set("SortBy", "DateLastContentAdded")
|
q.Set("SortBy", "DateLastContentAdded")
|
||||||
q.Set("SortOrder", "Descending")
|
q.Set("SortOrder", "Descending")
|
||||||
// 每页 200:Fields 带全量重字段(Overview/MediaStreams 等)时单页 1000
|
q.Set("Limit", strconv.Itoa(remoteSeriesPageSize))
|
||||||
// 条的载荷会超过 doGet 的 8MB 截断上限,JSON 被静默截断直接解析失败。
|
|
||||||
q.Set("Limit", "200")
|
|
||||||
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
||||||
var body struct {
|
var body struct {
|
||||||
Items []map[string]any `json:"Items"`
|
Items []map[string]any `json:"Items"`
|
||||||
TotalRecordCount int64 `json:"TotalRecordCount"`
|
TotalRecordCount int64 `json:"TotalRecordCount"`
|
||||||
}
|
}
|
||||||
cards := make([]SeriesCard, 0)
|
cards := make([]SeriesCard, 0)
|
||||||
for startIndex := 0; ; startIndex += 200 {
|
for startIndex := 0; ; startIndex += remoteSeriesPageSize {
|
||||||
q.Set("StartIndex", strconv.Itoa(startIndex))
|
q.Set("StartIndex", strconv.Itoa(startIndex))
|
||||||
body.Items = nil
|
body.Items = nil
|
||||||
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != 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 {
|
if date, ok := parseEmbyRemoteDate(remoteItemString(it, "DateLastMediaAdded")); ok {
|
||||||
lastAdded = &date
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -589,52 +599,14 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
|
|||||||
return cached, nil
|
return cached, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 剧集类媒体库:直接拉取最新入库/更新的 Series 剧集本身(按上次添加集日期倒序)。
|
items, err := r.RemoteLatestForDisplay(ctx, mount, acct, remoteViewID, limit)
|
||||||
// 避免 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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
cards := make([]SeriesCard, 0, len(items))
|
cards := make([]SeriesCard, 0, len(items))
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
||||||
|
isSeries := strings.EqualFold(strings.TrimSpace(remoteItemString(it, "Type")), "Series")
|
||||||
var lastAdded *time.Time
|
var lastAdded *time.Time
|
||||||
if !m.UpdatedAt.IsZero() {
|
if !m.UpdatedAt.IsZero() {
|
||||||
t := m.UpdatedAt
|
t := m.UpdatedAt
|
||||||
@@ -647,7 +619,17 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
|
|||||||
if count == 0 {
|
if count == 0 {
|
||||||
count = remoteItemInt(it, "ChildCount")
|
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 {
|
if r.cache != nil {
|
||||||
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
|
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -465,6 +466,9 @@ func TestRemoteLatestCardsTvShowsYearAndPoster(t *testing.T) {
|
|||||||
if cards[0].Count != 12 {
|
if cards[0].Count != 12 {
|
||||||
t.Fatalf("expected Count 12, got %d", cards[0].Count)
|
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 == "" {
|
if cards[0].Rep.PosterURL == "" {
|
||||||
t.Fatalf("expected PosterURL not empty")
|
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) {
|
func TestRemoteLatestFields(t *testing.T) {
|
||||||
var requestedFields string
|
var requestedFields string
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type SeriesCard struct {
|
|||||||
Rep model.Media `json:"rep"`
|
Rep model.Media `json:"rep"`
|
||||||
LinkMedia model.Media `json:"linkMedia"`
|
LinkMedia model.Media `json:"linkMedia"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
|
IsSeries bool `json:"is_series,omitempty"`
|
||||||
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
|
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +46,7 @@ type SeriesCardView struct {
|
|||||||
Rep model.Media `json:"rep"`
|
Rep model.Media `json:"rep"`
|
||||||
LinkLibraryID string `json:"linkLibraryId,omitempty"`
|
LinkLibraryID string `json:"linkLibraryId,omitempty"`
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
|
IsSeries bool `json:"is_series,omitempty"`
|
||||||
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
|
LastAddedAt *time.Time `json:"last_added_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +61,7 @@ func NewSeriesCardViews(cards []SeriesCard) []SeriesCardView {
|
|||||||
Rep: card.Rep,
|
Rep: card.Rep,
|
||||||
LinkLibraryID: mediaTargetLibraryID(card.LinkMedia),
|
LinkLibraryID: mediaTargetLibraryID(card.LinkMedia),
|
||||||
Count: card.Count,
|
Count: card.Count,
|
||||||
|
IsSeries: card.IsSeries,
|
||||||
LastAddedAt: card.LastAddedAt,
|
LastAddedAt: card.LastAddedAt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export function LibraryPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
resolvingSeries,
|
||||||
selectedEpisodes,
|
selectedEpisodes,
|
||||||
visibleEpisodes,
|
visibleEpisodes,
|
||||||
selectedSeriesEpisodes,
|
selectedSeriesEpisodes,
|
||||||
@@ -132,6 +133,7 @@ export function LibraryPage() {
|
|||||||
handleSeriesClick,
|
handleSeriesClick,
|
||||||
clearSelectedSeries,
|
clearSelectedSeries,
|
||||||
} = useLibrarySeriesSelection({
|
} = useLibrarySeriesSelection({
|
||||||
|
libraryID: id,
|
||||||
items,
|
items,
|
||||||
seriesEpisodeItems,
|
seriesEpisodeItems,
|
||||||
isSeriesLibrary,
|
isSeriesLibrary,
|
||||||
@@ -209,7 +211,9 @@ export function LibraryPage() {
|
|||||||
return <>{actions}</>
|
return <>{actions}</>
|
||||||
}, [canFavorite, favouriteBusyID, handleToggleFavourite, isFavourite, movieActions])
|
}, [canFavorite, favouriteBusyID, handleToggleFavourite, isFavourite, movieActions])
|
||||||
|
|
||||||
if (loading) {
|
// resolvingSeries:深链目标剧集不在已加载的一页卡片里,正在按 key
|
||||||
|
// 单独解析。此时先显示加载态,避免先渲染整个媒体库列表再跳走。
|
||||||
|
if (loading || resolvingSeries) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-32">
|
<div className="flex items-center justify-center py-32">
|
||||||
<motion.div animate={{ opacity: [0.4, 1, 0.4] }} transition={{ repeat: Infinity, duration: 2 }} className="flex items-center gap-3">
|
<motion.div animate={{ opacity: [0.4, 1, 0.4] }} transition={{ repeat: Infinity, duration: 2 }} className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
import type { Media } from '../types'
|
import type { Media } from '../types'
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
specialSectionForMedia,
|
specialSectionForMedia,
|
||||||
type SeriesCard,
|
type SeriesCard,
|
||||||
} from '../utils/groupSeries'
|
} from '../utils/groupSeries'
|
||||||
|
import { resolveSeriesCardByKey } from '../utils/seriesCardResolve'
|
||||||
|
|
||||||
type SeasonEpisodes = {
|
type SeasonEpisodes = {
|
||||||
season: number
|
season: number
|
||||||
@@ -16,6 +17,7 @@ type SeasonEpisodes = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UseLibrarySeriesSelectionOptions = {
|
type UseLibrarySeriesSelectionOptions = {
|
||||||
|
libraryID: string
|
||||||
items: Media[]
|
items: Media[]
|
||||||
seriesEpisodeItems: Media[]
|
seriesEpisodeItems: Media[]
|
||||||
isSeriesLibrary: boolean
|
isSeriesLibrary: boolean
|
||||||
@@ -32,6 +34,7 @@ type UseLibrarySeriesSelectionOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useLibrarySeriesSelection({
|
export function useLibrarySeriesSelection({
|
||||||
|
libraryID,
|
||||||
items,
|
items,
|
||||||
seriesEpisodeItems,
|
seriesEpisodeItems,
|
||||||
isSeriesLibrary,
|
isSeriesLibrary,
|
||||||
@@ -46,6 +49,14 @@ export function useLibrarySeriesSelection({
|
|||||||
setSelectedSeason,
|
setSelectedSeason,
|
||||||
onClearSeriesState,
|
onClearSeriesState,
|
||||||
}: UseLibrarySeriesSelectionOptions) {
|
}: UseLibrarySeriesSelectionOptions) {
|
||||||
|
// 深链(首页最新条目 / 海报墙 ?series=<key>)指向的剧集不在已加载的
|
||||||
|
// 一页卡片里时,需要单独按 key 解析;resolvingSeries 让页面在这段时间
|
||||||
|
// 显示加载态,而不是先闪一下整个媒体库列表。
|
||||||
|
const [resolvingSeries, setResolvingSeries] = useState(false)
|
||||||
|
const [pendingSeriesKey, setPendingSeriesKey] = useState('')
|
||||||
|
// 当前 URL 请求的剧集 key(非剧集库 / 加载中 / 没带参数时为空)。
|
||||||
|
const requestedSeriesKey = !loading && isSeries ? (searchParams.get('series') ?? '') : ''
|
||||||
|
|
||||||
const selectedEpisodes = useMemo(() => {
|
const selectedEpisodes = useMemo(() => {
|
||||||
const sourceItems = isSeriesLibrary ? seriesEpisodeItems : items
|
const sourceItems = isSeriesLibrary ? seriesEpisodeItems : items
|
||||||
if (!selectedSeries || sourceItems.length === 0) return []
|
if (!selectedSeries || sourceItems.length === 0) return []
|
||||||
@@ -92,20 +103,53 @@ export function useLibrarySeriesSelection({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return
|
if (loading) return
|
||||||
if (!isSeries) {
|
if (!isSeries) {
|
||||||
|
setPendingSeriesKey('')
|
||||||
setSelectedSeries(null)
|
setSelectedSeries(null)
|
||||||
setSelectedSeason(null)
|
setSelectedSeason(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = searchParams.get('series')
|
if (!requestedSeriesKey) {
|
||||||
if (!key) {
|
setPendingSeriesKey('')
|
||||||
setSelectedSeries(null)
|
setSelectedSeries(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = seriesCards.find((card) => card.key === key)
|
const next = seriesCards.find((card) => card.key === requestedSeriesKey)
|
||||||
setSelectedSeries(next ?? null)
|
if (next) {
|
||||||
}, [isSeries, loading, searchParams, seriesCards, setSelectedSeason, setSelectedSeries])
|
setPendingSeriesKey('')
|
||||||
|
setSelectedSeries(next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 目标剧集尚未加载到(远程库分页 / 大库分页):登记待解析 key。
|
||||||
|
setPendingSeriesKey((prev) => (prev === requestedSeriesKey ? prev : requestedSeriesKey))
|
||||||
|
}, [isSeries, loading, requestedSeriesKey, seriesCards, setSelectedSeason, setSelectedSeries])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// pendingSeriesKey 必须仍然是 URL 上请求的 key:切换媒体库/清空参数
|
||||||
|
// 时直接丢弃待解析状态,避免把别的库的剧集解析进来。
|
||||||
|
if (!pendingSeriesKey || pendingSeriesKey !== requestedSeriesKey) {
|
||||||
|
setResolvingSeries(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
setResolvingSeries(true)
|
||||||
|
resolveSeriesCardByKey(libraryID, pendingSeriesKey)
|
||||||
|
.then((card) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setResolvingSeries(false)
|
||||||
|
setSelectedSeries(card)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setResolvingSeries(false)
|
||||||
|
setSelectedSeries(null)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [libraryID, pendingSeriesKey, requestedSeriesKey, setSelectedSeries])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedSeries || selectedEpisodes.length === 0) {
|
if (!selectedSeries || selectedEpisodes.length === 0) {
|
||||||
@@ -135,6 +179,7 @@ export function useLibrarySeriesSelection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
resolvingSeries,
|
||||||
selectedEpisodes,
|
selectedEpisodes,
|
||||||
visibleEpisodes,
|
visibleEpisodes,
|
||||||
selectedSeriesEpisodes,
|
selectedSeriesEpisodes,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export type SeriesCard = {
|
|||||||
linkMedia?: Media
|
linkMedia?: Media
|
||||||
linkLibraryId?: string
|
linkLibraryId?: string
|
||||||
count: number
|
count: number
|
||||||
|
is_series?: boolean
|
||||||
last_added_at?: string
|
last_added_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +224,7 @@ export function seasonLabel(season: number): string {
|
|||||||
export function isSeriesCard(card: SeriesCard): boolean {
|
export function isSeriesCard(card: SeriesCard): boolean {
|
||||||
const linkMedia = card.linkMedia ?? card.rep
|
const linkMedia = card.linkMedia ?? card.rep
|
||||||
return (
|
return (
|
||||||
|
card.is_series === true ||
|
||||||
card.count > 1 ||
|
card.count > 1 ||
|
||||||
isEpisodeLike(card.rep) ||
|
isEpisodeLike(card.rep) ||
|
||||||
isEpisodeLike(linkMedia) ||
|
isEpisodeLike(linkMedia) ||
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { libraryAPI, mediaAPI } from '../api/library'
|
||||||
|
import type { Media } from '../types'
|
||||||
|
import { groupSeries, type SeriesCard } from './groupSeries'
|
||||||
|
import { isRemoteEmbyID } from './remoteEmby'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 series key 反解出剧集卡片。
|
||||||
|
*
|
||||||
|
* 首页「最新条目」、海报墙等入口点击后带 ?series=<key> 深链进媒体库页,
|
||||||
|
* 但媒体库页每次只加载一页剧集卡片(远程 Emby 库 100 条),目标剧集不在
|
||||||
|
* 这一页时列表里找不到对应卡片,页面就退化成整个媒体库列表。这里按 key
|
||||||
|
* 单独解析目标剧集,与分页无关:
|
||||||
|
*
|
||||||
|
* - 远程 Emby 的 key 本身就是(伪装后的)剧集 ID,直接取该条目详情;
|
||||||
|
* - 本地 key 是分组 hash,改取该剧集的集列表,再折叠出代表条目。
|
||||||
|
*
|
||||||
|
* key 失效(剧集已删除等)时返回 null,调用方保持"整库列表"原状态。
|
||||||
|
*/
|
||||||
|
export async function resolveSeriesCardByKey(libraryID: string, key: string): Promise<SeriesCard | null> {
|
||||||
|
if (!libraryID || !key) return null
|
||||||
|
|
||||||
|
if (isRemoteEmbyID(key)) {
|
||||||
|
const rep = await fetchRemoteSeriesMedia(key)
|
||||||
|
if (rep) {
|
||||||
|
return { key, rep, linkMedia: rep, count: 0, is_series: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodes = await fetchSeriesEpisodes(libraryID, key)
|
||||||
|
if (episodes.length === 0) return null
|
||||||
|
// 与列表页同一套折叠规则,选出海报/元数据最好的那一集做代表卡片。
|
||||||
|
const [grouped] = groupSeries(episodes)
|
||||||
|
const rep = grouped?.rep ?? episodes[0]
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
rep,
|
||||||
|
linkMedia: grouped?.linkMedia ?? rep,
|
||||||
|
count: episodes.length,
|
||||||
|
is_series: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRemoteSeriesMedia(id: string): Promise<Media | null> {
|
||||||
|
try {
|
||||||
|
const media = await mediaAPI.get(id)
|
||||||
|
return media?.id ? media : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSeriesEpisodes(libraryID: string, key: string): Promise<Media[]> {
|
||||||
|
try {
|
||||||
|
const page = await libraryAPI.listSeriesEpisodes(libraryID, key)
|
||||||
|
return Array.isArray(page.items) ? page.items : []
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user