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:
truewhile
2026-09-01 10:44:01 +08:00
parent 89d7a6cbb2
commit 0eb3f104f4
21 changed files with 1748 additions and 15 deletions
+98 -1
View File
@@ -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}
}