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 } if favorite, _ := IsUserFavorite(ctx, e.repo, userID, mediaID); favorite { userData, _ := out["UserData"].(map[string]any) if userData == nil { userData = map[string]any{} out["UserData"] = userData } userData["IsFavorite"] = true } 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.RemoteLatest(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 列出有未完成播放进度的媒体。 func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) { return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit}) } // 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 } // 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 } localIDs := make([]string, 0, len(hist)) for _, h := range hist { if !IsEmbyRemoteID(h.MediaID) { localIDs = append(localIDs, h.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] } } // 分页前置:凑满 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 != "" && m.LibraryID != p.ParentID && m.SeriesID != p.ParentID { continue } localTotal++ slots = append(slots, resumeSlot{item: e.itemPayload(ctx, m, false, 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"] = mergedRemoteUserData(item["UserData"], &f.hist) 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) if episodeTitle != "" { name = episodeTitle } else if 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) } return item } 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]) }