mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 13:26:38 +08:00
feat: Emby 远程挂载(联邦聚合)功能 beta
- 新增 emby_remote 提供方:通过 STRM 账号体系配置远程 Emby 地址/用户名/密码
- EmbyService 聚合远程媒体库 Views/Items/详情/最近添加/全局搜索,远程数据不落库
- 条目 ID 统一伪装为 embyremote~{account}~{id},请求按账号路由回远程
- 播放支持账号级 proxy_play 配置:默认客户端直连远程;开启后由 MMTL 反向代理流/字幕
- 图片/元数据全部实时透传远程,播放状态(已看/收藏)透传回远程 Emby
- cloud 包新增 emby Provider(Ping/List/Resolve),账号测试/目录浏览自动生效
- 前端 STRM 页面支持 Emby 远程挂载账号(地址+凭据+代理开关)
This commit is contained in:
@@ -48,6 +48,7 @@ type EmbyService struct {
|
||||
repo *repository.Container
|
||||
cache *RuntimeCacheService
|
||||
subtitle *SubtitleService
|
||||
remote *EmbyRemoteService // 远程 Emby 联邦聚合(可为 nil:未启用)
|
||||
|
||||
virtualMu sync.RWMutex
|
||||
virtualSeries map[string]embySeriesCacheEntry
|
||||
@@ -66,6 +67,14 @@ func NewEmbyService(cfg *config.Config, log *zap.Logger, repo *repository.Contai
|
||||
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
|
||||
@@ -123,7 +132,8 @@ type embyVisibilityCacheEntry struct {
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
@@ -135,6 +145,22 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
||||
return emptyItemsEnvelope(p.StartIndex), nil
|
||||
}
|
||||
|
||||
if e.remote != nil {
|
||||
// 远程目录浏览:ParentId 带远程前缀 → 完整转发给远程 Emby 承接分页。
|
||||
if IsEmbyRemoteID(p.ParentID) {
|
||||
acctID, _, _ := DecodeEmbyRemoteID(p.ParentID)
|
||||
acct := e.remote.AccountByID(ctx, acctID)
|
||||
if acct == nil {
|
||||
return emptyItemsEnvelope(p.StartIndex), nil
|
||||
}
|
||||
return e.remote.RemoteItems(ctx, acct, p)
|
||||
}
|
||||
// 全局搜索:无 ParentId 且带搜索词 → 聚合本地 + 全部远程。
|
||||
if p.ParentID == "" && p.SearchTerm != "" {
|
||||
return e.aggregatedSearch(ctx, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(p.IDs) > 0 {
|
||||
items := make([]map[string]any, 0, len(p.IDs))
|
||||
for _, id := range p.IDs {
|
||||
@@ -212,3 +238,74 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
||||
}
|
||||
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
|
||||
}
|
||||
accounts, aerr := e.remote.ListAccounts(ctx)
|
||||
results := make([]remoteResult, 0, len(accounts))
|
||||
if aerr == nil {
|
||||
for i := range accounts {
|
||||
acct := &accounts[i]
|
||||
remote, rerr := e.remote.RemoteItems(ctx, 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 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}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user