mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
e8c710c261
Unify favourite writes through SyncUserFavorite so remote mounted media is stored locally and proxied to the upstream Emby server. Emby clients now query favourites from the local table (including remote pseudo IDs), and remote item payloads merge local IsFavorite state. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: truewhile <truewhile@users.noreply.github.com>
343 lines
12 KiB
Go
343 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
|
||
}
|
||
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
|
||
}
|
||
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}
|
||
}
|