mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
2b99f5f108
Mounted Emby libraries were always appended to web/Emby library lists and detail/play routes without checking allowed_library_ids, so restricted users could still see and open them. Filter remotes with the same visibility policy as local libraries across list/detail/series/stream and Emby Views/Items/search/playback, and label mounts in the admin ACL UI. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: truewhile <truewhile@users.noreply.github.com>
349 lines
12 KiB
Go
349 lines
12 KiB
Go
// 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
|
||
// MeBox 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/truewhile/MeBox/internal/config"
|
||
"github.com/truewhile/MeBox/internal/repository"
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
// 用一个固定的 ServerId 字符串。Emby 客户端会缓存这个 id,第一次见到
|
||
// 该 id 后会把所有派生数据(cookie/收藏/历史)和它绑定。
|
||
const embyServerID = "mebox-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) {
|
||
if containsEmbyFilter(p.Filters, "IsFavorite") {
|
||
return e.favoriteItems(ctx, p)
|
||
}
|
||
mountID, _, _ := DecodeEmbyRemoteID(p.ParentID)
|
||
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
|
||
if mount == nil || acct == nil {
|
||
return emptyItemsEnvelope(p.StartIndex), nil
|
||
}
|
||
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, p.UserID), mount) {
|
||
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 containsEmbyFilter(p.Filters, "IsFavorite") {
|
||
return e.favoriteItems(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
|
||
}
|
||
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, p.UserID), &m) {
|
||
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}
|
||
}
|