Files
MeBox/internal/service/emby_compat.go
T
truewhile 541de2165e 优化客户端封面获取逻辑
修改emby客户端获取封面逻辑,修复没有设置封面导致封面为空的问题
2026-08-20 09:20:42 +08:00

236 lines
8.4 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 — Emby/Jellyfin compatibility shim.
//
// EmbyService produces JSON envelopes shaped like the most-consumed
// Emby-API endpoints so existing players (Infuse / Yamby / Hills /
// Senplayer / Kodi NextPVR extension / iOS native clients) can talk to
// MediaStationGo without a custom plugin.
//
// The shim is read-mostly: items, images, playback are fully covered;
// 播放进度上报 / 收藏切换 是写路径但走我们自己的 PlaybackHistory /
// Favorite 表,所以 Emby 客户端的"标记已看 / 收藏"也会反向同步到
// 我们自己的 React UI。
package service
import (
"context"
"regexp"
"sync"
"time"
"github.com/ShukeBta/MediaStationGo/internal/config"
"github.com/ShukeBta/MediaStationGo/internal/repository"
"github.com/ShukeBta/MediaStationGo/internal/service/cloud"
"go.uber.org/zap"
)
// 用一个固定的 ServerId 字符串。Emby 客户端会缓存这个 id,第一次见到
// 该 id 后会把所有派生数据(cookie/收藏/历史)和它绑定。
const embyServerID = "mediastation-go-001"
// embyCompatVersion deliberately reports an Emby 4.x server. Official Emby
// clients reject Jellyfin-style 10.x identities as unsupported/too old during
// the login handshake, even when the API shape is compatible enough for us.
const embyCompatVersion = "4.8.10.0"
const (
embyLocalAuthenticationProviderID = "Emby.Server.Implementations.LocalAuthenticationProvider" // #nosec G101 -- Emby provider identifier, not a credential.
embyLocalPasswordResetProviderID = "Emby.Server.Implementations.LocalPasswordResetProvider" // #nosec G101 -- Emby provider identifier, not a credential.
)
// PlaybackDirectOnlySettingKey 控制「客户端直连解码」模式:开启后宿主机
// 不再提供转码,所有播放交给第三方客户端本地解码(direct play / 302 直链),
// 以释放宿主机 CPU 资源。
const PlaybackDirectOnlySettingKey = "playback.direct_only"
// EmbyService produces Emby-shaped JSON.
type EmbyService struct {
cfg *config.Config
log *zap.Logger
repo *repository.Container
storage cloudPlaybackResolver
probe cloudPlaybackProber
cache *RuntimeCacheService
subtitle *SubtitleService
virtualMu sync.RWMutex
virtualSeries map[string]embySeriesCacheEntry
virtualSeasons map[string]embySeasonCacheEntry
virtualArtwork map[string]embyArtworkCacheEntry
visibilityMu sync.RWMutex
visibilityCache map[string]embyVisibilityCacheEntry
cloudProbeMu sync.Mutex
cloudProbeInFlight map[string]struct{}
libraryCoverMu sync.Mutex
libraryCoverCache map[string]embyArtworkCacheEntry
}
type cloudPlaybackResolver interface {
CloudResolve(ctx context.Context, typ, fileRef, clientUA string) (*cloud.DirectLink, error)
}
type cloudPlaybackProber interface {
ProbeHTTP(ctx context.Context, rawURL string, headers map[string]string) (*ProbeResult, error)
}
// NewEmbyService is the constructor.
func NewEmbyService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *EmbyService {
return &EmbyService{cfg: cfg, log: log, repo: repo}
}
func (e *EmbyService) SetRuntimeCache(cache *RuntimeCacheService) *EmbyService {
if e != nil {
e.cache = cache
}
return e
}
func (e *EmbyService) SetCloudProbe(storage cloudPlaybackResolver, probe cloudPlaybackProber) {
if e == nil {
return
}
e.storage = storage
e.probe = probe
}
// SetSubtitleService wires the external-subtitle discovery service into the
// Emby shim so MediaStreams can advertise sideloaded subtitle tracks. It is
// nil-safe: when subtitle is nil, mediaStreams simply emits no subtitle
// streams and keeps the pre-existing Video/Audio behaviour.
func (e *EmbyService) SetSubtitleService(subtitle *SubtitleService) *EmbyService {
if e != nil {
e.subtitle = subtitle
}
return e
}
// ─── Items ───────────────────────────────────────────────────────────────────
// ItemsParams 是 /Items 与 /Users/{uid}/Items 共用的查询参数。
type ItemsParams struct {
UserID string
ParentID string
IDs []string
SearchTerm string
IncludeItemTypes []string
Filters []string
Recursive bool
SortBy string
SortOrder string
Limit int
StartIndex int
}
const (
embyVirtualSeriesPrefix = "msgo-series-"
embyVirtualSeasonPrefix = "msgo-season-"
embyVirtualCacheTTL = 10 * time.Minute
embyVisibilityCacheTTL = 30 * time.Second
embySeriesGroupingLimit = maxMediaSearchLimit
)
var (
embySeasonDirRE = regexp.MustCompile(`(?i)^(season[\s._-]*\d+|s\d+|specials?|sp|ova|oad|extra|extras|第\s*[0-9一二三四五六七八九十百零两]+\s*季|特别篇|特別篇|番外|特典)$`)
embyYearSuffixRE = regexp.MustCompile(`\s*[\((\[]\d{4}[\))\]]\s*$`)
embyEpisodeTitleRE = regexp.MustCompile(`(?i)\s*[-_ ]*s\d{1,2}e\d{1,3}.*$`)
)
type embyVisibilityCacheEntry struct {
visibility MediaVisibility
expiresAt time.Time
}
// Items paginates media in Emby's hierarchy. Episodic libraries are exposed as
// Series -> Season -> Episode so Infuse/Vidhub/SenPlayer stop treating every
// episode as a separate movie card.
func (e *EmbyService) Items(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 len(p.IncludeItemTypes) > 0 && !containsSupportedEmbyItemType(p.IncludeItemTypes) {
return emptyItemsEnvelope(p.StartIndex), nil
}
if len(p.IDs) > 0 {
items := make([]map[string]any, 0, len(p.IDs))
for _, id := range p.IDs {
item, err := e.Item(ctx, id, p.UserID)
if err != nil {
return nil, err
}
if item != nil {
items = append(items, item)
}
}
return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil
}
if containsOnlyFolderItemTypes(p.IncludeItemTypes) {
if p.ParentID == "" {
return e.Views(ctx, p.UserID)
}
if episodic, err := e.libraryIsEpisodic(ctx, p.ParentID); err != nil {
return nil, err
} else if episodic {
return e.seriesItemsForLibrary(ctx, p.ParentID, p)
}
return map[string]any{"Items": []map[string]any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}, nil
}
if p.ParentID == "" && p.SearchTerm == "" && !p.Recursive && len(p.IncludeItemTypes) == 0 && len(p.Filters) == 0 {
return e.Views(ctx, p.UserID)
}
if season, ok, err := e.findSeasonGroup(ctx, p.ParentID, p.UserID); err != nil {
return nil, err
} else if ok {
return e.episodeItems(ctx, season.Episodes, p)
}
if series, ok, err := e.findSeriesGroup(ctx, p.ParentID, p.UserID); err != nil {
return nil, err
} else if ok {
if p.Recursive || containsItemType(p.IncludeItemTypes, "Episode") {
return e.episodeItems(ctx, series.Episodes, p)
}
seasons := e.seasonsForSeries(series)
items := make([]map[string]any, 0, len(seasons))
for _, season := range pageSlice(seasons, p.StartIndex, p.Limit) {
items = append(items, e.seasonPayload(season))
}
return map[string]any{"Items": items, "TotalRecordCount": len(seasons), "StartIndex": p.StartIndex}, nil
}
if p.ParentID != "" {
if episodic, err := e.libraryIsEpisodic(ctx, p.ParentID); err != nil {
return nil, err
} else if episodic && !p.Recursive && !containsItemType(p.IncludeItemTypes, "Episode") {
return e.seriesItemsForLibrary(ctx, p.ParentID, p)
}
}
if containsItemType(p.IncludeItemTypes, "Series") && !containsItemType(p.IncludeItemTypes, "Episode") {
return e.seriesItemsForLibrary(ctx, p.ParentID, p)
}
// 电影库的「常规浏览」(未指定 IncludeItemTypes): 电影库里偶尔混入按
// Season/SxxE 结构整理的内容(如整合成剧集的剧场版 / 合集动画)。这些行若按
// 散装单集(Episode)漏出,在 Infuse/yamby 等客户端表现为「整部剧被拆成一堆
// 单集卡片」。方案 B: 把它们按整剧聚成 Series 卡片,与真正的电影(Movie)并列
// 展示在同一电影库视图。仅在该电影库确实含此类内容时才走此分支,普通电影库
// 仍走 mediaItems(保留其缓存 / 版本合并逻辑)。
if p.ParentID != "" && !p.Recursive && len(p.IncludeItemTypes) == 0 {
if episodic, err := e.libraryIsEpisodic(ctx, p.ParentID); err == nil && !episodic {
if has, err := e.movieLibraryHasEpisodicContent(ctx, p.ParentID); err == nil && has {
return e.movieLibraryItems(ctx, p)
}
}
}
return e.mediaItems(ctx, p)
}