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
+26 -17
View File
@@ -100,11 +100,12 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
wl := webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
if withPreview {
if acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID); acct != nil {
tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}}
itemTypes := remoteLibraryItemTypes(v.CollectionType)
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
wl.Total = total
}
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, acct, v.RemoteID, limit); err == nil {
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
wl.Cards = cards
}
}
@@ -123,8 +124,8 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
id := c.Param("id")
// 远程 Emby 挂载库详情。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
acctID, remoteID, _ := service.DecodeEmbyRemoteID(id)
view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, acctID, remoteID)
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, mountID, remoteID)
if err != nil || view == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
@@ -302,17 +303,17 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
// 远程 Emby 库:转发远程直属条目并映射为本地 Media 结构(分页由远程承接)。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
acctID, remoteID, _ := service.DecodeEmbyRemoteID(id)
acct := svc.EmbyRemote.AccountByID(ctx, acctID)
if acct == nil {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
itemTypes := ""
if view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, acctID, remoteID); err == nil && view != nil {
if view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, mountID, remoteID); err == nil && view != nil {
itemTypes = remoteLibraryItemTypes(view.CollectionType)
}
items, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, acct, remoteID, itemTypes, (page-1)*size, size)
items, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, mount, acct, remoteID, itemTypes, (page-1)*size, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -369,13 +370,13 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
id := c.Param("id")
// 远程 Emby 条目:拉远程详情并映射为本地 Media 结构。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
acctID, remoteID, _ := service.DecodeEmbyRemoteID(id)
acct := svc.EmbyRemote.AccountByID(ctx, acctID)
if acct == nil {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
m, err := svc.EmbyRemote.RemoteMediaDetail(ctx, acct, remoteID)
m, err := svc.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -487,14 +488,22 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
// 远程 Emby 条目:302 直连远程流(网页播放器 video 标签跟随重定向)。
// 远程 Emby 条目:按挂载代理配置分流——代理走 MMTL 反代,否则 302 直连。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
acctID, remoteID, _ := service.DecodeEmbyRemoteID(id)
acct := svc.EmbyRemote.AccountByID(ctx, acctID)
if acct == nil {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if mount.ProxyPlay {
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
return
}
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})