feat: Emby 挂载独立管理(媒体库级选择挂载 + 每库代理开关)

- 新增 EmbyMount 表与 /admin/emby/mounts CRUD:把远程 Emby 的媒体库按需
  挂载到本项目(同一服务器可选择性挂载/全量挂载),代理开关下沉到每个
  挂载(一个 Emby 服务器可部分库代理、部分库直连)
- 伪装 ID 改为 embyremote~{mountID}~{remoteID}:播放/详情/状态/图片链路
  全部经 ResolveMount 解析挂载与账号
- 新增「Emby 挂载」独立菜单页(/emby-mount):账号管理(添加/测试/编辑/
  删除)+ 挂载列表(代理切换/停用/取消)+ 选择挂载对话框(多选+全量)
- STRM 管理页过滤 emby_remote 账号;账号表单移除 emby 类型
- Boot 幂等 AutoSeedMounts:旧 emby 账号自动全量挂载(沿用旧代理配置),
  升级后媒体库不消失
- 修复:远程流请求注入浏览器 UA(防 Cloudflare 风控)+ 代理强制 Static=true
  (阻止远程 ffmpeg 转码调度,反代纯字节中继)
- 全局搜索改为按挂载逐个搜索(结果归属与伪装 ID 正确)
This commit is contained in:
truewhile
2026-09-01 13:53:15 +08:00
parent 5a5555d28a
commit 73b95a8e38
24 changed files with 1331 additions and 186 deletions
+14 -53
View File
@@ -5,8 +5,6 @@ import (
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/model"
)
@@ -148,62 +146,38 @@ func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any,
return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil
}
// remoteViews 拉取全部启用远程 Emby 账号的媒体库视图。单个账号失败只跳过
// 该账号(日志记录),不影响其他来源。
// remoteViews 返回全部启用挂载的远程媒体库视图(只有显式挂载的库才出现)。
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 {
views, err := e.remote.RemoteLibraries(ctx)
if err != nil || len(views) == 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)
}
}
out := make([]map[string]any, 0, len(views))
for _, v := range views {
out = append(out, remoteLibraryViewPayload(v))
}
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)
// remoteLibraryViewPayload 把挂载库展示信息标准化为 Emby View payload
// (ID 用挂载伪装,名称=挂载显示名)。
func remoteLibraryViewPayload(v RemoteLibraryView) map[string]any {
encoded := EncodeEmbyRemoteID(v.MountID, v.RemoteID)
collectionType := v.CollectionType
if !isSupportedEmbyCollectionType(collectionType) {
collectionType = "mixed"
}
encoded := EncodeEmbyRemoteID(acct.ID, remoteID)
name := strings.TrimSpace(v.Library.Name)
imageTags := map[string]string{}
if primary := embyRemoteImageTagsPrimary(raw); primary != "" {
if strings.TrimSpace(v.Library.CoverURL) != "" {
imageTags["Primary"] = encoded
}
return map[string]any{
"Id": encoded,
"Name": acct.Name + " · " + name,
"Name": name,
"CollectionType": collectionType,
"ServerId": embyServerID,
"Type": "CollectionFolder",
@@ -237,19 +211,6 @@ func (e *EmbyService) remoteLibraryAsView(raw map[string]any, acct *model.StrmAc
}
}
// 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":