Files
MeBox/internal/service/emby_compat.go
T
2026-09-01 23:16:11 +08:00

337 lines
12 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
// MMTL 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/MMTL/internal/config"
"github.com/ShukeBta/MMTL/internal/repository"
"go.uber.org/zap"
)
// 用一个固定的 ServerId 字符串。Emby 客户端会缓存这个 id,第一次见到
// 该 id 后会把所有派生数据(cookie/收藏/历史)和它绑定。
const embyServerID = "mmtl-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
cache *RuntimeCacheService
subtitle *SubtitleService
remote *EmbyRemoteService // 远程 Emby 联邦聚合(可为 nil:未启用)
virtualMu sync.RWMutex
virtualSeries map[string]embySeriesCacheEntry
virtualSeasons map[string]embySeasonCacheEntry
virtualArtwork map[string]embyArtworkCacheEntry
visibilityMu sync.RWMutex
visibilityCache map[string]embyVisibilityCacheEntry
libraryCoverMu sync.Mutex
libraryCoverCache map[string]embyArtworkCacheEntry
}
// NewEmbyService is the constructor.
func NewEmbyService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *EmbyService {
return &EmbyService{cfg: cfg, log: log, repo: repo}
}
// SetEmbyRemote 注入远程 Emby 联邦聚合服务(nil 表示未启用)。
func (e *EmbyService) SetEmbyRemote(remote *EmbyRemoteService) *EmbyService {
if e != nil {
e.remote = remote
}
return e
}
func (e *EmbyService) SetRuntimeCache(cache *RuntimeCacheService) *EmbyService {
if e != nil {
e.cache = cache
}
return e
}
// 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*季|特别篇|特別篇|番外|特典)$`)
embySeasonSuffixRE = regexp.MustCompile(`(?i)(?:[\s._-]+(?:season[\s._-]*\d+|s\d+|第\s*[0-9一二三四五六七八九十百零两]+\s*季|specials?|sp|ova|oad|extra|extras|特别篇|特別篇|番外|特典)|\s*第\s*[0-9一二三四五六七八九十百零两]+\s*季)\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. 带 embyremote~ 前缀的 ParentID / 搜索自动
// 路由到远程 Emby(联邦聚合,远程数据不落库)。
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 e.remote != nil {
// 远程目录浏览:ParentId 带远程前缀 → 完整转发给远程 Emby 承接分页。
if IsEmbyRemoteID(p.ParentID) {
mountID, _, _ := DecodeEmbyRemoteID(p.ParentID)
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
return emptyItemsEnvelope(p.StartIndex), nil
}
out, err := e.remote.RemoteItems(ctx, mount, acct, p)
if err != nil {
return nil, err
}
if err := e.mergeRemoteUserData(ctx, p.UserID, out); err != nil {
return nil, err
}
return out, nil
}
// 全局搜索:无 ParentId 且带搜索词 → 聚合本地 + 全部远程。
if p.ParentID == "" && p.SearchTerm != "" {
return e.aggregatedSearch(ctx, p)
}
}
if containsEmbyFilter(p.Filters, "IsResumable") {
return e.resumableItems(ctx, p)
}
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)
}
// aggregatedSearch 把本地媒体库与全部启用的远程 Emby 的搜索结果合并为一个
// 分页载荷。本地结果保持原有分页语义,远程各自取一页(Limit 同款)后按
// SortBy 做稳定排序切片。
func (e *EmbyService) aggregatedSearch(ctx context.Context, p ItemsParams) (map[string]any, error) {
local, err := e.mediaItems(ctx, p)
if err != nil {
return nil, err
}
type remoteResult struct {
items []any
}
mounts, aerr := e.remote.ListMounts(ctx)
results := make([]remoteResult, 0, len(mounts))
if aerr == nil {
for i := range mounts {
m := mounts[i]
if !m.Enabled {
continue
}
acct := e.remote.AccountByID(ctx, m.AccountID)
if acct == nil {
continue
}
// 按挂载逐个搜索:搜索结果归属明确(伪装 ID 正确),也天然只搜已
// 挂载的媒体库。
searchParams := p
searchParams.ParentID = "" // RemoteSearchMount 内部设 ParentId
remote, rerr := e.remote.RemoteSearchMount(ctx, &m, acct, p)
if rerr != nil {
if e.log != nil {
e.log.Warn("remote emby search failed",
zap.String("account", acct.Name), zap.Error(rerr))
}
continue
}
if err := e.mergeRemoteUserData(ctx, p.UserID, remote); err != nil {
return nil, err
}
if raw, ok := remote["Items"].([]any); ok {
results = append(results, remoteResult{items: raw})
} else if rawMap, ok := remote["Items"].([]map[string]any); ok {
converted := make([]any, 0, len(rawMap))
for _, m := range rawMap {
converted = append(converted, any(m))
}
results = append(results, remoteResult{items: converted})
}
}
}
items := make([]any, 0, len(localItemsAsAny(local))+len(results)*p.Limit)
items = append(items, localItemsAsAny(local)...)
for _, res := range results {
items = append(items, res.items...)
}
return sliceSearchItems(items, p), nil
}
func localItemsAsAny(envelope map[string]any) []any {
if envelope == nil {
return nil
}
if raw, ok := envelope["Items"].([]any); ok {
return raw
}
if raw, ok := envelope["Items"].([]map[string]any); ok {
converted := make([]any, 0, len(raw))
for _, m := range raw {
converted = append(converted, any(m))
}
return converted
}
return nil
}
// sliceSearchItems 对合并结果按请求排序做简单归类后分页。远程返回已按远程
// 排序规则排好,这里保持稳定顺序,只做首/尾切片,避免过度重排造成分页跳动。
func sliceSearchItems(items []any, p ItemsParams) map[string]any {
total := len(items)
if p.StartIndex >= total {
return map[string]any{"Items": []any{}, "TotalRecordCount": total, "StartIndex": p.StartIndex}
}
end := minInt(p.StartIndex+p.Limit, total)
return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}
}