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
+118 -2
View File
@@ -5,6 +5,8 @@ import (
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/model"
)
@@ -124,7 +126,8 @@ func (e *EmbyService) userPayload(u *model.User) map[string]any {
}
}
// Views 返回 Emby 中"虚拟根目录"——每个 library 一个条目。
// Views 返回 Emby 中"虚拟根目录"——每个 library 一个条目,外加所有启用的
// 远程 Emby 挂载的媒体库(联邦聚合)。
func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any, error) {
libs, err := e.repo.Library.List(ctx)
if err != nil {
@@ -132,16 +135,129 @@ func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any,
}
libs = FilterDisplayCloudLibraries(ctx, e.repo, libs)
visibility := e.mediaVisibility(ctx, userID)
items := make([]map[string]any, 0, len(libs))
items := make([]map[string]any, 0, len(libs)+4)
for _, l := range libs {
if !e.libraryVisibleFromCachedVisibility(l, visibility) {
continue
}
items = append(items, e.libraryAsView(ctx, &l))
}
for _, remote := range e.remoteViews(ctx) {
items = append(items, remote)
}
return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil
}
// remoteViews 拉取全部启用远程 Emby 账号的媒体库视图。单个账号失败只跳过
// 该账号(日志记录),不影响其他来源。
func (e *EmbyService) remoteViews(ctx context.Context) []map[string]any {
if e == nil || e.remote == nil {
return nil
}
accounts, err := e.remote.ListAccounts(ctx)
if err != nil || len(accounts) == 0 {
return nil
}
out := make([]map[string]any, 0, len(accounts)*2)
for i := range accounts {
acct := &accounts[i]
views, err := e.remote.RemoteViews(ctx, acct)
if err != nil {
if e.log != nil {
e.log.Warn("fetch remote emby views failed",
zap.String("account", acct.Name), zap.Error(err))
}
continue
}
for _, v := range views {
if view := e.remoteLibraryAsView(v, acct); view != nil {
out = append(out, view)
}
}
}
return out
}
// remoteLibraryAsView 把远程媒体的 CollectionFolder 视图标准化为本地视图
// 同构的 payload(ID 伪装、名称前缀账号名以便区分多个远程来源)。
func (e *EmbyService) remoteLibraryAsView(raw map[string]any, acct *model.StrmAccount) map[string]any {
if raw == nil {
return nil
}
remoteID, _ := raw["Id"].(string)
if remoteID == "" {
return nil
}
name, _ := raw["Name"].(string)
if name == "" {
name = acct.Name
}
collectionType, _ := raw["CollectionType"].(string)
if !isSupportedEmbyCollectionType(collectionType) {
collectionType = "mixed"
}
encoded := EncodeEmbyRemoteID(acct.ID, remoteID)
imageTags := map[string]string{}
if primary := embyRemoteImageTagsPrimary(raw); primary != "" {
imageTags["Primary"] = encoded
}
return map[string]any{
"Id": encoded,
"Name": acct.Name + " · " + name,
"CollectionType": collectionType,
"ServerId": embyServerID,
"Type": "CollectionFolder",
"IsFolder": true,
"Path": "",
"SortName": strings.ToLower(name),
"DateCreated": time.Now().UTC().Format(time.RFC3339),
"CanDelete": false,
"CanDownload": false,
"DisplayPreferencesId": encoded,
"PrimaryImageItemId": encoded,
"PrimaryImageAspectRatio": 1.7777777777777777,
"RecursiveItemCount": 0,
"ChildCount": 0,
"SpecialFeatureCount": 0,
"EnableMediaSourceDisplay": true,
"PlayAccess": "Full",
"ExternalUrls": []any{},
"ProviderIds": map[string]string{},
"Genres": []string{},
"Tags": []string{},
"ImageTags": imageTags,
"BackdropImageTags": []string{},
"UserData": map[string]any{
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"UnplayedItemCount": 0,
},
}
}
// embyRemoteImageTagsPrimary 提取远程 View 的 ImageTags.Primary 值。
func embyRemoteImageTagsPrimary(raw map[string]any) string {
switch tags := raw["ImageTags"].(type) {
case map[string]any:
if s, ok := tags["Primary"].(string); ok {
return s
}
case map[string]string:
return tags["Primary"]
}
return ""
}
func isSupportedEmbyCollectionType(t string) bool {
switch t {
case "movies", "tvshows", "music", "mixed", "homevideos", "boxsets":
return true
}
return false
}
func (e *EmbyService) libraryAsView(ctx context.Context, l *model.Library) map[string]any {
collectionType := "movies"
switch l.Type {