Files
MeBox/internal/service/emby_items_detail.go
T
truewhile 8136e6ecb1 fix(emby): 电影条目不下发空的 SeasonId/SeriesId,修复小幻影视起播失败
小幻影视(RodelPlayer)2.2610 会把电影条目的 SeasonId 空串原样传进自己的
播放准备逻辑并抛 ArgumentException(Parameter 'seasonId'),客户端日志里
表现为 failedStage=PreparationFailed、playback generation null,也就是
点播放立刻「播放失败」;剧集条目的 SeasonId 非空,所以同一台设备放剧集正常。

Emby/Jellyfin 对「不适用」的字段是直接不下发(JSON 里没有该键)而不是下发空串。
这里对非剧集条目省略 SeasonId/SeriesId/SeasonName/SeriesName(顺带去掉电影被
标成「特别篇」的 SeasonName),剧集则保留真实季/剧信息、只清空值。

同时回退上一版针对 115 直链 UA 绑定的同源一跳改动:客户端日志显示该客户端的
播放器层与 API 层 UA 是一致的(剧集走服务端换链后的一跳直连可以正常解码播放),
不需要为此牺牲起播速度。
2026-09-28 22:51:40 +08:00

1060 lines
31 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
)
// Item 单条目详情。
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
if e == nil {
return nil, nil
}
// 远程 Emby 条目:不查本地库,直接向远程转发(保持远程最新元数据)。
if e.remote != nil && IsEmbyRemoteID(mediaID) {
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
return nil, nil
}
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
return nil, nil
}
out, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
if err != nil || out == nil {
return out, err
}
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return nil, err
}
return out, nil
}
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil {
return nil, err
} else if lib != nil {
libs := FilterDisplayCloudLibraries(ctx, e.repo, []model.Library{*lib})
if len(libs) == 0 {
return nil, nil
}
visibility := e.mediaVisibility(ctx, userID)
if !e.libraryVisibleFromCachedVisibility(libs[0], visibility) {
return nil, nil
}
return e.libraryAsView(ctx, &libs[0]), nil
}
if strings.HasPrefix(mediaID, embyVirtualSeasonPrefix) {
if season, ok, err := e.findSeasonGroup(ctx, mediaID, userID); err != nil {
return nil, err
} else if ok {
item := e.seasonPayload(season)
if media := seriesPeopleMedia(season.Series); media != nil {
item["People"] = e.resolveMediaPeople(ctx, media)
}
return item, nil
}
}
if strings.HasPrefix(mediaID, embyVirtualSeriesPrefix) {
if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil {
return nil, err
} else if ok {
item := e.seriesPayload(series)
if media := seriesPeopleMedia(series); media != nil {
item["People"] = e.resolveMediaPeople(ctx, media)
}
return item, nil
}
}
m, err := e.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return nil, err
}
if m == nil {
if series, ok, err := e.findSeriesGroup(ctx, mediaID, userID); err != nil {
return nil, err
} else if ok {
item := e.seriesPayload(series)
if media := seriesPeopleMedia(series); media != nil {
item["People"] = e.resolveMediaPeople(ctx, media)
}
return item, nil
}
return nil, nil
}
if !UserDefaultMediaVisibility(ctx, e.repo, userID).Allows(m) {
return nil, nil
}
fav := false
pos := int64(0)
if userID != "" {
var f model.Favorite
ferr := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID).First(&f).Error
if ferr == nil {
fav = true
}
var h model.PlaybackHistory
herr := e.repo.DB.WithContext(ctx).Where("user_id = ? AND media_id = ?", userID, mediaID).
Order("watched_at desc").First(&h).Error
if herr == nil {
pos = h.PositionMs
}
}
// 单条目 payload 内部对库类型/series 标题有多次查找,挂请求级缓存合并。
return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos, true), nil
}
// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)走远程并缓存。
func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, limit int) ([]map[string]any, error) {
if e == nil {
return nil, nil
}
if limit <= 0 || limit > 100 {
limit = 20
}
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
if items, stale, ok := e.cachedLatestItemsWithStale(ctx, cacheKey); ok {
if stale {
e.refreshLatestItemsAsync(cacheKey, userID, parentID, limit)
}
return items, nil
}
value, err := e.loadLatestItemsCached(ctx, userID, parentID, limit)
if err != nil {
return nil, err
}
if value.Items == nil {
return []map[string]any{}, nil
}
return value.Items, nil
}
func (e *EmbyService) loadLatestItemsCached(ctx context.Context, userID, parentID string, limit int) (embyLatestCacheValue, error) {
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
// 一个客户端断开不应取消正在为其他客户端填充的共享重建。
loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 45*time.Second)
defer cancel()
v, err, _ := e.latestFlight.Do(cacheKey, func() (any, error) {
if items, ok := e.cachedLatestItems(loadCtx, cacheKey); ok {
return embyLatestCacheValue{Items: items}, nil
}
value, err := e.loadLatestItems(loadCtx, userID, parentID, limit)
if err != nil {
return nil, err
}
if e.cache != nil {
freshTTL := time.Duration(e.embyLatestCacheTTLSeconds()) * time.Second
e.cache.SetJSONWithStale(loadCtx, cacheKey, value, freshTTL, freshTTL+30*time.Minute)
}
e.rememberArtworkRefs(value.Artwork)
return value, nil
})
if err != nil {
return embyLatestCacheValue{}, err
}
cached, _ := v.(embyLatestCacheValue)
return cached, nil
}
func (e *EmbyService) refreshLatestItemsAsync(cacheKey, userID, parentID string, limit int) {
if e == nil || e.cache == nil {
return
}
if _, loaded := e.latestRefresh.LoadOrStore(cacheKey, struct{}{}); loaded {
return
}
go func() {
defer e.latestRefresh.Delete(cacheKey)
if _, err := e.loadLatestItemsCached(context.Background(), userID, parentID, limit); err != nil && e.log != nil {
e.log.Debug("background refresh of latest items failed",
zap.String("parent_id", parentID),
zap.Error(redactSensitiveError(err)))
}
}()
}
func (e *EmbyService) cachedLatestItems(ctx context.Context, cacheKey string) ([]map[string]any, bool) {
items, stale, ok := e.cachedLatestItemsWithStale(ctx, cacheKey)
return items, ok && !stale
}
func (e *EmbyService) cachedLatestItemsWithStale(ctx context.Context, cacheKey string) ([]map[string]any, bool, bool) {
if e == nil || e.cache == nil {
return nil, false, false
}
var cached embyLatestCacheValue
found, stale := e.cache.GetJSONStale(ctx, cacheKey, &cached)
if !found {
return nil, false, false
}
e.rememberArtworkRefs(cached.Artwork)
if cached.Items == nil {
return []map[string]any{}, stale, true
}
return cached.Items, stale, true
}
func (e *EmbyService) loadLatestItems(ctx context.Context, userID, parentID string, limit int) (embyLatestCacheValue, error) {
if e.remote != nil && IsEmbyRemoteID(parentID) {
mountID, remoteParent, _ := DecodeEmbyRemoteID(parentID)
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil || !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
return embyLatestCacheValue{Items: []map[string]any{}}, nil
}
out, err := e.remote.RemoteLatestForDisplay(ctx, mount, acct, remoteParent, limit)
if err != nil {
return embyLatestCacheValue{}, err
}
if out == nil {
out = []map[string]any{}
}
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
return embyLatestCacheValue{}, err
}
return embyLatestCacheValue{Items: out}, nil
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("deleted_at IS NULL")
q = e.applyUserMediaVisibility(ctx, q, userID)
if parentID != "" {
if episodic, err := e.libraryIsEpisodic(ctx, parentID); err == nil && episodic {
out, artwork, err := e.latestSeriesItemsForLibrary(ctx, userID, parentID, limit)
if err != nil {
return embyLatestCacheValue{}, err
}
return embyLatestCacheValue{Items: out, Artwork: artwork}, nil
}
q = q.Where("library_id IN ?", e.mergedLibraryIDs(ctx, parentID))
}
rowLimit := limit * 4
if rowLimit < 100 {
rowLimit = 100
}
if rowLimit > 500 {
rowLimit = 500
}
var rows []model.Media
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(rowLimit).Find(&rows).Error; err != nil {
return embyLatestCacheValue{}, err
}
rows = e.collapseMediaVersionRows(ctx, rows)
if len(rows) > limit {
rows = rows[:limit]
}
out, err := e.payloadsForMedia(ctx, rows, userID)
if err != nil {
return embyLatestCacheValue{}, err
}
return embyLatestCacheValue{Items: out}, nil
}
func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, libraryID string, limit int) ([]map[string]any, map[string]embyArtworkRef, error) {
if limit <= 0 || limit > 100 {
limit = 20
}
q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).
Where("library_id IN ? AND (season_num > 0 OR episode_num > 0)", e.mergedLibraryIDs(ctx, libraryID))
q = e.applyUserMediaVisibility(ctx, q, userID)
var rows []model.Media
// 只要最近几部剧的卡片,不要把整库 5 万行拉进内存再丢掉。
if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embyLatestSeriesRowLimit(limit)).Find(&rows).Error; err != nil {
return nil, nil, err
}
groups := e.seriesGroupsFromMedia(ctx, rows)
sortSeriesGroups(groups, ItemsParams{SortBy: "premieredate", SortOrder: "Descending"})
if len(groups) > limit {
groups = groups[:limit]
}
items := make([]map[string]any, 0, len(groups))
for _, group := range groups {
items = append(items, e.seriesPayload(group))
}
return items, e.artworkRefsForSeriesGroups(groups), nil
}
// embyLatestSeriesRowLimit 是 Latest 剧集卡片的候选窗口。按「每部剧最近约 40
// 集」估,刚好覆盖现有 25 部 × 40 集的分组测试,又不会为了 16 张海报去扫整库。
// ponytail: 一部长剧如果占满窗口,更老的剧不会出现在 Latest 行;点进媒体库
// 仍走完整分组。上限 2000 行。
func embyLatestSeriesRowLimit(limit int) int {
if limit < 1 {
limit = 20
}
n := limit * 40
if n < 200 {
n = 200
}
if n > 2000 {
n = 2000
}
return n
}
// ResumeItems 列出有未完成播放进度的媒体。
// parentID 非空时收窄到该库 / 该剧(含虚拟 msgo-series-* ID)。
func (e *EmbyService) ResumeItems(ctx context.Context, userID, parentID string, limit, startIndex int) (map[string]any, error) {
return e.resumableItems(ctx, ItemsParams{
UserID: userID,
ParentID: strings.TrimSpace(parentID),
Limit: limit,
StartIndex: startIndex,
})
}
// favoriteItems returns favourited media for Emby clients, including mounted
// remote items stored only in the local favourites table.
func (e *EmbyService) favoriteItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
if p.Limit <= 0 || p.Limit > 500 {
p.Limit = 50
}
if p.StartIndex < 0 {
p.StartIndex = 0
}
if strings.TrimSpace(p.UserID) == "" {
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
}
var favs []model.Favorite
if err := e.repo.DB.WithContext(ctx).
Where("user_id = ?", p.UserID).
Order("created_at desc").
Find(&favs).Error; err != nil {
return nil, err
}
if len(favs) == 0 {
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
}
localIDs := make([]string, 0, len(favs))
for _, fav := range favs {
if !IsEmbyRemoteID(fav.MediaID) {
localIDs = append(localIDs, fav.MediaID)
}
}
byID := map[string]*model.Media{}
if len(localIDs) > 0 {
var medias []model.Media
q := e.repo.DB.WithContext(ctx).Where("id IN ?", localIDs)
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
if err := q.Find(&medias).Error; err != nil {
return nil, err
}
for i := range medias {
byID[medias[i].ID] = &medias[i]
}
}
items := make([]map[string]any, 0, len(favs))
for _, fav := range favs {
if m, ok := byID[fav.MediaID]; ok {
if !favoriteMatchesParent(ctx, e, p.ParentID, fav.MediaID, m.LibraryID, m.SeriesID, nil) {
continue
}
if p.SearchTerm != "" {
needle := strings.ToLower(p.SearchTerm)
if !strings.Contains(strings.ToLower(m.Title), needle) &&
!strings.Contains(strings.ToLower(m.OriginalName), needle) {
continue
}
}
items = append(items, e.itemPayload(ctx, m, true, 0, false))
continue
}
if e.remote == nil || !IsEmbyRemoteID(fav.MediaID) {
continue
}
mountID, remoteID, _ := DecodeEmbyRemoteID(fav.MediaID)
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
continue
}
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteID)
if err != nil || item == nil {
continue
}
if !favoriteMatchesParent(ctx, e, p.ParentID, fav.MediaID, "", "", item) {
continue
}
if p.SearchTerm != "" {
needle := strings.ToLower(p.SearchTerm)
name, _ := item["Name"].(string)
orig, _ := item["OriginalTitle"].(string)
if !strings.Contains(strings.ToLower(name), needle) &&
!strings.Contains(strings.ToLower(orig), needle) {
continue
}
}
userData, _ := item["UserData"].(map[string]any)
if userData == nil {
userData = map[string]any{}
item["UserData"] = userData
}
userData["IsFavorite"] = true
items = append(items, item)
}
total := int64(len(items))
if p.StartIndex >= len(items) {
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
}
end := minInt(p.StartIndex+p.Limit, len(items))
return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
}
func favoriteMatchesParent(ctx context.Context, e *EmbyService, parentID, mediaID, libraryID, seriesID string, remoteItem map[string]any) bool {
if parentID == "" {
return true
}
if libraryID != "" {
if libraryID == parentID || seriesID == parentID {
return true
}
for _, id := range e.mergedLibraryIDs(ctx, parentID) {
if id == libraryID {
return true
}
}
return false
}
if remoteItem == nil {
return false
}
itemParent, _ := remoteItem["ParentId"].(string)
itemSeries, _ := remoteItem["SeriesId"].(string)
if itemParent == parentID || itemSeries == parentID || mediaID == parentID {
return true
}
if !IsEmbyRemoteID(parentID) {
return false
}
wantMountID, _, _ := DecodeEmbyRemoteID(parentID)
gotMountID, _, _ := DecodeEmbyRemoteID(mediaID)
return wantMountID != "" && gotMountID == wantMountID
}
// resumeMatchesParent 判断续播条目是否属于 ParentId / SeriesId 作用域。
// 本地剧集的 series_id 常为空,实际对外 ID 是 msgo-series-* 虚拟 ID,必须用
// seriesIDForMedia 对齐,否则按剧收窄永远匹配不上。
func resumeMatchesParent(ctx context.Context, e *EmbyService, parentID, libraryID, seriesID string, m *model.Media) bool {
if parentID == "" {
return true
}
if libraryID == parentID || seriesID == parentID {
return true
}
if m != nil && e.seriesIDForMedia(ctx, m) == parentID {
return true
}
if m != nil && e.seasonIDForMedia(ctx, m) == parentID {
return true
}
for _, id := range e.mergedLibraryIDs(ctx, parentID) {
if id == libraryID {
return true
}
}
return false
}
// resumableItems 返回未完成播放进度的媒体(包含本地媒体与挂载的远程媒体),支持分页。
func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
if p.Limit <= 0 || p.Limit > 100 {
p.Limit = 50
}
if p.StartIndex < 0 {
p.StartIndex = 0
}
if strings.TrimSpace(p.UserID) == "" {
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
}
// 历史记录限行:此前无上限全量加载,远程条目多时既拖慢 SQL 也放大
// 下面的远程详情请求量。
var hist []model.PlaybackHistory
if err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND completed = ? AND position_ms > 0", p.UserID, false).
Order("watched_at desc").Limit(200).Find(&hist).Error; err != nil {
return nil, err
}
if len(hist) == 0 {
return map[string]any{"Items": []any{}, "TotalRecordCount": int64(0), "StartIndex": p.StartIndex}, nil
}
mediaIDs := make([]string, 0, len(hist))
localIDs := make([]string, 0, len(hist))
for _, h := range hist {
mediaIDs = append(mediaIDs, h.MediaID)
if !IsEmbyRemoteID(h.MediaID) {
localIDs = append(localIDs, h.MediaID)
}
}
favSet := map[string]bool{}
if len(mediaIDs) > 0 {
var favs []model.Favorite
if err := e.repo.DB.WithContext(ctx).
Where("user_id = ? AND media_id IN ?", p.UserID, mediaIDs).
Find(&favs).Error; err != nil {
return nil, err
}
for _, fav := range favs {
favSet[fav.MediaID] = true
}
}
byID := map[string]*model.Media{}
if len(localIDs) > 0 {
var medias []model.Media
q := e.repo.DB.WithContext(ctx).Where("id IN ?", localIDs)
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
if err := q.Find(&medias).Error; err != nil {
return nil, err
}
for i := range medias {
byID[medias[i].ID] = &medias[i]
}
}
// 分页前置:凑满 StartIndex+Limit 条即停,不再为「总数」逐条发远程
// 详情 GET(此前每条远程记录一次串行 GET,远程慢时请求挂起数分钟)。
// 总数用候选行数(本地过滤后 + 远程候选),对继续观看行的翻页语义
// 足够准确。
needed := p.StartIndex + p.Limit
slots := make([]resumeSlot, 0, len(hist))
localTotal, remoteTotal := 0, 0
for _, h := range hist {
if m, ok := byID[h.MediaID]; ok {
if p.ParentID != "" && !resumeMatchesParent(ctx, e, p.ParentID, m.LibraryID, m.SeriesID, m) {
continue
}
localTotal++
slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, favSet[h.MediaID], h.PositionMs, false)})
continue
}
if e.remote == nil || !IsEmbyRemoteID(h.MediaID) {
continue
}
remoteTotal++
slots = append(slots, resumeSlot{fetch: &resumeRemoteFetch{hist: h}})
}
for {
filled := 0
pending := make([]*resumeRemoteFetch, 0)
for i := range slots {
if slots[i].item != nil {
filled++
if filled >= needed {
break
}
continue
}
f := slots[i].fetch
if f == nil || f.done {
continue
}
if f.mount == nil {
mountID, remoteID, _ := DecodeEmbyRemoteID(f.hist.MediaID)
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
if err != nil || mount == nil || acct == nil {
f.done = true
continue
}
f.mount, f.acct, f.remoteID = mount, acct, remoteID
}
pending = append(pending, f)
if filled+len(pending) >= needed {
break
}
}
if len(pending) == 0 {
break
}
e.fillResumeRemotes(ctx, pending)
for i := range slots {
f := slots[i].fetch
if f == nil || !pendingContains(pending, f) {
continue
}
f.done = true
item := f.item
f.item = nil
if item == nil {
continue
}
if p.ParentID != "" {
parentID, _ := item["ParentId"].(string)
seriesID, _ := item["SeriesId"].(string)
mountID, _, _ := DecodeEmbyRemoteID(f.hist.MediaID)
if parentID != p.ParentID && seriesID != p.ParentID && mountID != p.ParentID {
continue
}
}
item["UserData"] = applyMeBoxUserData(item["UserData"], &f.hist, favSet[f.hist.MediaID])
slots[i].item = item
}
}
items := make([]map[string]any, 0, p.Limit)
for i := range slots {
if slots[i].item == nil {
continue
}
items = append(items, slots[i].item)
}
total := int64(localTotal + remoteTotal)
if p.StartIndex >= len(items) {
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
}
end := minInt(p.StartIndex+p.Limit, len(items))
return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}, nil
}
type resumeSlot struct {
item map[string]any
fetch *resumeRemoteFetch
}
type resumeRemoteFetch struct {
hist model.PlaybackHistory
mount *model.EmbyMount
acct *model.StrmAccount
remoteID string
item map[string]any
done bool
}
func pendingContains(rows []*resumeRemoteFetch, target *resumeRemoteFetch) bool {
for _, row := range rows {
if row == target {
return true
}
}
return false
}
// fillResumeRemotes 并行补齐继续观看里的远程条目。同一页以前是串行 GET,
// 远程一慢就把整个 Resume 拖到数秒。结果按原播放历史顺序回填。
func (e *EmbyService) fillResumeRemotes(ctx context.Context, rows []*resumeRemoteFetch) {
if e == nil || e.remote == nil || len(rows) == 0 {
return
}
var wg sync.WaitGroup
sem := make(chan struct{}, 4)
for i := range rows {
wg.Add(1)
go func(i int) {
defer wg.Done()
select {
case sem <- struct{}{}:
case <-ctx.Done():
return
}
defer func() { <-sem }()
item, err := e.remote.RemoteItem(ctx, rows[i].mount, rows[i].acct, rows[i].remoteID)
if err != nil || item == nil {
return
}
rows[i].item = item
}(i)
}
wg.Wait()
}
func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, posMs int64, includePeople bool) map[string]any {
itemType := "Movie"
name := m.Title
parentID := m.LibraryID
seriesID := m.SeriesID
seriesName := ""
seasonID := ""
seasonNum := m.SeasonNum
if e.mediaShouldBeEpisode(ctx, m) {
itemType = "Episode"
seriesID = e.seriesIDForMedia(ctx, m)
seriesName = e.seriesNameForMedia(ctx, m)
seasonNum = embySeasonNumForMedia(m)
seasonID = e.seasonIDForMedia(ctx, m)
parentID = seasonID
episodeTitle := strings.TrimSpace(m.EpisodeTitle)
// 半集(S01E11.5)在 Emby 协议里只能占用整数 IndexNumber 11,标题里
// 不再带上小数就会和真正的第 11 集完全分不清,所以显式补上集号。
halfLabel := ""
if m.EpisodeFraction > 0 && m.EpisodeNum > 0 {
halfLabel = fmt.Sprintf("第 %s 集", FormatEpisodeNumber(m.EpisodeNum, m.EpisodeFraction))
}
switch {
case episodeTitle != "":
name = episodeTitle
if halfLabel != "" && !strings.HasPrefix(episodeTitle, halfLabel) {
name = halfLabel + " · " + episodeTitle
}
case halfLabel != "":
name = halfLabel
case m.EpisodeNum > 0:
name = fmt.Sprintf("第 %d 集", m.EpisodeNum)
}
}
imageTags := map[string]string{}
backdropTags := []string{}
primaryArtwork := e.mediaPrimaryArtwork(ctx, m)
backdropArtwork := e.mediaBackdropArtwork(ctx, m)
if primaryArtwork != "" {
imageTags["Primary"] = m.ID
}
if backdropArtwork != "" {
backdropTags = append(backdropTags, m.ID+"-bd")
}
runTimeTicks := int64(m.DurationSec) * 10_000_000
durationMs := int64(m.DurationSec) * 1000
if durationMs <= 0 && posMs > 0 {
durationMs = posMs * 2
if durationMs < 30*60*1000 {
durationMs = 30 * 60 * 1000
}
runTimeTicks = durationMs * 10_000
}
played := posMs > 0 && durationMs > 0 && posMs >= durationMs*9/10
pct := 0.0
if durationMs > 0 {
pct = float64(posMs) / float64(durationMs) * 100
}
item := map[string]any{
"Id": m.ID,
"Name": name,
"OriginalTitle": m.OriginalName,
"ServerId": embyServerID,
"Type": itemType,
"MediaType": "Video",
"IsFolder": false,
"ProductionYear": m.Year,
"ParentIndexNumber": seasonNum,
"IndexNumber": m.EpisodeNum,
"Overview": m.Overview,
"RunTimeTicks": runTimeTicks,
"CommunityRating": m.Rating,
"Container": m.Container,
"Width": m.Width,
"Height": m.Height,
"DateCreated": m.CreatedAt,
"Path": m.Path,
"ParentId": parentID,
"SeasonId": seasonID,
"SeasonName": seasonName(seasonNum),
"SeriesId": seriesID,
"SeriesName": seriesName,
"ImageTags": imageTags,
"BackdropImageTags": backdropTags,
"Genres": splitCSV(m.Genres),
"People": []map[string]any{},
"ProviderIds": map[string]string{
"Tmdb": intToStr(m.TMDbID),
"Bangumi": intToStr(m.BangumiID),
},
"UserData": map[string]any{
"PlaybackPositionTicks": posMs * 10_000,
"PlayCount": 0,
"IsFavorite": fav,
"Played": played,
"PlayedPercentage": pct,
},
"MediaSources": e.mediaSourcesForItem(ctx, m, true, false),
}
if primaryArtwork != "" {
item["PrimaryImageTag"] = m.ID
}
if seriesID != "" {
if sEntry, ok, _ := e.payloadSeriesEntry(ctx, seriesID); ok {
if sEntry.posterURL != "" {
item["SeriesPrimaryImageTag"] = embyVirtualImageTag(seriesID, embyVirtualPrimaryTagSuffix)
}
if len(backdropTags) == 0 && (sEntry.backdropURL != "" || sEntry.posterURL != "") {
item["ParentBackdropItemId"] = seriesID
item["ParentBackdropImageTags"] = []string{embyVirtualImageTag(seriesID, embyVirtualBackdropTagSuffix)}
}
}
}
if premiered, ok := embyPremiereDate(m.ReleaseDate); ok {
item["PremiereDate"] = premiered
}
if includePeople {
item["People"] = e.resolveMediaPeople(ctx, m)
}
// Emby/Jellyfin 对「不适用」的字段是直接不下发(JSON 里没有该键),而不是下发
// 空串或占位值。电影等非剧集条目没有季/剧归属,小幻影视(RodelPlayer)2.2610
// 会把 "SeasonId": "" 原样传进播放准备逻辑并抛 ArgumentException
// (Parameter 'seasonId'),起播直接失败;"SeasonName": "特别篇" 也会让客户端
// 把电影当成特别篇展示。剧集则保留真实的季/剧信息,只清掉空值。
seasonKeys := []string{"SeasonId", "SeriesId", "SeasonName", "SeriesName"}
if itemType == "Episode" {
dropEmptyStringFields(item, seasonKeys...)
} else {
for _, key := range seasonKeys {
delete(item, key)
}
}
return item
}
// dropEmptyStringFields 删除值为空串(或全空白)的字符串字段。
//
// 用于对齐 Emby/Jellyfin 的序列化行为:这些字段在没有值时是「不存在」,
// 而不是空串。客户端普遍把空串当合法值使用,收到空串会当成非法参数。
func dropEmptyStringFields(item map[string]any, keys ...string) {
for _, key := range keys {
if value, ok := item[key].(string); ok && strings.TrimSpace(value) == "" {
delete(item, key)
}
}
}
func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []map[string]any {
if m == nil {
return []map[string]any{}
}
cacheKey := embyMediaPeopleCacheKey(m)
if cacheKey == "" {
return []map[string]any{}
}
if people, ok := e.cachedMediaPeople(cacheKey); ok {
return people
}
people := e.fetchTMDbPeople(ctx, m)
if len(people) == 0 {
people = e.resolveNFOMediaPeople(m)
}
e.rememberMediaPeople(cacheKey, people)
return people
}
func seriesPeopleMedia(group embySeriesGroup) *model.Media {
if group.TMDbID <= 0 {
return nil
}
if len(group.Episodes) > 0 {
media := group.Episodes[0]
media.TMDbID = group.TMDbID
return &media
}
return &model.Media{Title: group.Name, TMDbID: group.TMDbID, SeasonNum: 1}
}
func (e *EmbyService) fetchTMDbPeople(ctx context.Context, m *model.Media) []map[string]any {
if e == nil || m == nil {
return nil
}
var people []map[string]any
if e.tmdb != nil && m.TMDbID > 0 {
mediaType := "movie"
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
mediaType = "tv"
}
people, err := e.tmdb.GetPeople(ctx, m.TMDbID, mediaType)
if err != nil {
if e.log != nil {
e.log.Debug("tmdb: detail people lookup failed", zap.Int("tmdb_id", m.TMDbID), zap.String("type", mediaType), zap.Error(err))
}
} else if len(people) > 0 {
e.rememberPersonImages(people)
return people
}
}
if m.NSFW && e.adult != nil {
people = e.adult.GetPeople(ctx, m)
}
return people
}
func embyMediaPeopleCacheKey(m *model.Media) string {
if m == nil {
return ""
}
if m.TMDbID > 0 {
mediaType := "movie"
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
mediaType = "tv"
}
return fmt.Sprintf("tmdb:%d:%s", m.TMDbID, mediaType)
}
if strings.TrimSpace(m.ID) != "" {
return "media:" + strings.TrimSpace(m.ID)
}
if strings.TrimSpace(m.Path) != "" {
return "path:" + strings.ToLower(filepath.Clean(m.Path))
}
return ""
}
func (e *EmbyService) resolveNFOMediaPeople(m *model.Media) []map[string]any {
if m == nil || strings.TrimSpace(m.Path) == "" {
return []map[string]any{}
}
dir := filepath.Dir(m.Path)
candidates := make([]string, 0, 6)
seenPath := map[string]struct{}{}
add := func(path string) {
path = strings.TrimSpace(path)
if path == "" {
return
}
key := strings.ToLower(filepath.Clean(path))
if _, ok := seenPath[key]; ok {
return
}
seenPath[key] = struct{}{}
candidates = append(candidates, path)
}
// 共享词干优先(竞女01.mkv.strm → 竞女01.nfo),并兼容旧的单层剥扩展命名。
add(nfoPath(m.Path))
for _, base := range mediaSidecarBaseVariants(m.Path) {
add(filepath.Join(dir, base+".nfo"))
}
add(filepath.Join(dir, "movie.nfo"))
add(filepath.Join(dir, "tvshow.nfo"))
people := make([]map[string]any, 0)
seen := make(map[string]bool)
for _, p := range candidates {
if fi, err := os.Stat(p); err == nil && !fi.IsDir() {
doc, _, err := decodeNFOFile(p)
if err == nil && doc != nil {
for _, d := range doc.Directors {
name := strings.TrimSpace(d)
if name == "" {
continue
}
personID := embyPersonID(name, "Director")
if seen[personID] {
continue
}
seen[personID] = true
people = append(people, map[string]any{
"Id": personID,
"Name": name,
"Type": "Director",
"Role": "Director",
})
}
for _, a := range doc.Actors {
name := strings.TrimSpace(a.Name)
if name == "" {
continue
}
personID := embyPersonID(name, "Actor")
if seen[personID] {
continue
}
seen[personID] = true
role := strings.TrimSpace(a.Role)
if role == "" {
role = "Actor"
}
people = append(people, map[string]any{
"Id": personID,
"Name": name,
"Type": "Actor",
"Role": role,
})
}
break
}
}
}
return people
}
func (e *EmbyService) cachedMediaPeople(key string) ([]map[string]any, bool) {
if e == nil || strings.TrimSpace(key) == "" {
return nil, false
}
now := time.Now()
e.peopleMu.RLock()
entry, ok := e.peopleCache[key]
e.peopleMu.RUnlock()
if !ok || now.After(entry.expiresAt) {
if ok {
e.peopleMu.Lock()
delete(e.peopleCache, key)
e.peopleMu.Unlock()
}
return nil, false
}
out := make([]map[string]any, len(entry.people))
copy(out, entry.people)
return out, true
}
func (e *EmbyService) rememberMediaPeople(key string, people []map[string]any) {
if e == nil || strings.TrimSpace(key) == "" {
return
}
e.peopleMu.Lock()
defer e.peopleMu.Unlock()
if e.peopleCache == nil || len(e.peopleCache) > 8000 {
e.peopleCache = make(map[string]embyPeopleCacheEntry, 128)
}
stored := make([]map[string]any, len(people))
copy(stored, people)
ttl := embyPeopleCacheTTL
if len(stored) == 0 {
ttl = embyPeopleEmptyCacheTTL
}
e.peopleCache[key] = embyPeopleCacheEntry{
people: stored,
expiresAt: time.Now().Add(ttl),
}
}
func (e *EmbyService) rememberPersonImages(people []map[string]any) {
if e == nil || len(people) == 0 {
return
}
e.personImageMu.Lock()
defer e.personImageMu.Unlock()
if e.personImages == nil || len(e.personImages) > 20000 {
e.personImages = make(map[string]string, 128)
}
for _, person := range people {
id := strings.TrimSpace(fmt.Sprint(person["Id"]))
profilePath := tmdbProfilePathFromTag(fmt.Sprint(person["PrimaryImageTag"]))
if _, ok := parseTMDbPersonID(id); ok && profilePath != "" {
e.personImages[id] = profilePath
}
}
}
func (e *EmbyService) cachedPersonImage(id string) string {
if e == nil {
return ""
}
e.personImageMu.RLock()
profilePath := e.personImages[strings.TrimSpace(id)]
e.personImageMu.RUnlock()
return profilePath
}
func (e *EmbyService) rememberPersonImage(id, profilePath string) {
profilePath = strings.TrimSpace(profilePath)
if e == nil || strings.TrimSpace(id) == "" || !tmdbProfilePathRE.MatchString(profilePath) {
return
}
e.personImageMu.Lock()
if e.personImages == nil {
e.personImages = make(map[string]string, 128)
}
e.personImages[strings.TrimSpace(id)] = profilePath
e.personImageMu.Unlock()
}
func embyPersonID(name, roleType string) string {
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(name)) + ":" + strings.ToLower(strings.TrimSpace(roleType))))
return "person-" + hex.EncodeToString(sum[:8])
}