mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 19:46:38 +08:00
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:
@@ -0,0 +1,198 @@
|
||||
// Emby 挂载管理 HTTP 层:远程 Emby 服务器(账号)下的媒体库挂载 CRUD,
|
||||
// 以及账号远程媒体库(View)列表预览。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/service"
|
||||
)
|
||||
|
||||
// embyMountView 挂载的对外 JSON(附带账号信息)。
|
||||
type embyMountView struct {
|
||||
model.EmbyMount
|
||||
AccountName string `json:"account_name"`
|
||||
}
|
||||
|
||||
// embyMountInput 创建挂载的请求体(单个或批量)。
|
||||
type embyMountInput struct {
|
||||
AccountID string `json:"account_id" binding:"required"`
|
||||
Views []embyViewInput `json:"views" binding:"required,min=1"`
|
||||
}
|
||||
|
||||
type embyViewInput struct {
|
||||
RemoteViewID string `json:"remote_view_id" binding:"required"`
|
||||
RemoteViewName string `json:"remote_view_name"`
|
||||
CollectionType string `json:"collection_type"`
|
||||
Name string `json:"name"`
|
||||
ProxyPlay bool `json:"proxy_play"`
|
||||
}
|
||||
|
||||
func embyMountViews(mounts []model.EmbyMount, accounts map[string]string) []embyMountView {
|
||||
out := make([]embyMountView, 0, len(mounts))
|
||||
for _, m := range mounts {
|
||||
out = append(out, embyMountView{EmbyMount: m, AccountName: accounts[m.AccountID]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// embyAccountViewsHandler 列出账号上的远程媒体库(View),供挂载选择。
|
||||
func embyAccountViewsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
acct := svc.EmbyRemote.AccountByID(c.Request.Context(), c.Param("id"))
|
||||
if acct == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "账号不存在或已禁用"})
|
||||
return
|
||||
}
|
||||
views, err := svc.EmbyRemote.RemoteViews(c.Request.Context(), acct)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
type viewEntry struct {
|
||||
RemoteViewID string `json:"remote_view_id"`
|
||||
RemoteViewName string `json:"remote_view_name"`
|
||||
CollectionType string `json:"collection_type"`
|
||||
ChildCount int `json:"child_count"`
|
||||
AlreadyMounted bool `json:"already_mounted"`
|
||||
}
|
||||
mounted := map[string]bool{}
|
||||
if mounts, err := svc.EmbyRemote.ListMountsByAccount(c.Request.Context(), acct.ID); err == nil {
|
||||
for _, m := range mounts {
|
||||
mounted[m.RemoteViewID] = true
|
||||
}
|
||||
}
|
||||
out := make([]viewEntry, 0, len(views))
|
||||
for _, v := range views {
|
||||
viewID := service.RemoteItemIDString(v)
|
||||
if strings.TrimSpace(viewID) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, viewEntry{
|
||||
RemoteViewID: viewID,
|
||||
RemoteViewName: service.RemoteItemNameString(v),
|
||||
CollectionType: service.RemoteItemCollectionType(v),
|
||||
ChildCount: service.RemoteItemChildCount(v),
|
||||
AlreadyMounted: mounted[viewID],
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// listEmbyMountsHandler 列出全部挂载。
|
||||
func listEmbyMountsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
mounts, err := svc.EmbyRemote.ListMounts(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
names := map[string]string{}
|
||||
if accounts, err := svc.EmbyRemote.ListAccounts(c.Request.Context()); err == nil {
|
||||
for _, a := range accounts {
|
||||
names[a.ID] = a.Name
|
||||
}
|
||||
}
|
||||
out := embyMountViews(mounts, names)
|
||||
if out == nil {
|
||||
out = []embyMountView{}
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// createEmbyMountsHandler 批量创建挂载(同一账号下的多个远程媒体库)。
|
||||
func createEmbyMountsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req embyMountInput
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
mounts := make([]*model.EmbyMount, 0, len(req.Views))
|
||||
for _, v := range req.Views {
|
||||
mounts = append(mounts, &model.EmbyMount{
|
||||
AccountID: req.AccountID,
|
||||
RemoteViewID: v.RemoteViewID,
|
||||
RemoteViewName: v.RemoteViewName,
|
||||
CollectionType: v.CollectionType,
|
||||
Name: v.Name,
|
||||
ProxyPlay: v.ProxyPlay,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
if _, err := svc.EmbyRemote.CreateMounts(c.Request.Context(), mounts); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "created": len(mounts)})
|
||||
}
|
||||
}
|
||||
|
||||
// fullMountEmbyAccountHandler 全量挂载:把账号所有远程媒体库一次挂载进来。
|
||||
func fullMountEmbyAccountHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
acct := svc.EmbyRemote.AccountByID(c.Request.Context(), c.Param("id"))
|
||||
if acct == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "账号不存在或已禁用"})
|
||||
return
|
||||
}
|
||||
proxy := c.Query("proxy") == "1" || c.Query("proxy") == "true"
|
||||
n, err := svc.EmbyRemote.FullMountAccount(c.Request.Context(), acct, proxy)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "created": n})
|
||||
}
|
||||
}
|
||||
|
||||
// updateEmbyMountHandler 更新挂载(显示名 / 代理开关 / 启用)。
|
||||
func updateEmbyMountHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
ProxyPlay *bool `json:"proxy_play"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
mount, err := svc.EmbyRemote.MountByID(c.Request.Context(), c.Param("id"))
|
||||
if err != nil || mount == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "挂载不存在"})
|
||||
return
|
||||
}
|
||||
if req.Name != nil {
|
||||
mount.Name = *req.Name
|
||||
}
|
||||
if req.ProxyPlay != nil {
|
||||
mount.ProxyPlay = *req.ProxyPlay
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
mount.Enabled = *req.Enabled
|
||||
}
|
||||
if _, err := svc.EmbyRemote.UpdateMount(c.Request.Context(), mount.ID, mount); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, mount)
|
||||
}
|
||||
}
|
||||
|
||||
// deleteEmbyMountHandler 删除挂载。
|
||||
func deleteEmbyMountHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := svc.EmbyRemote.DeleteMount(c.Request.Context(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
}
|
||||
+26
-17
@@ -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()})
|
||||
|
||||
@@ -46,6 +46,14 @@ func registerAdminScraperRoutes(admin *gin.RouterGroup, svc *service.Container)
|
||||
}
|
||||
|
||||
func registerAdminStrmRoutes(admin *gin.RouterGroup, svc *service.Container) {
|
||||
// Emby 挂载管理:远程 Emby 媒体库挂载(账号复用 strm/accounts)
|
||||
admin.GET("/emby/accounts/:id/views", embyAccountViewsHandler(svc))
|
||||
admin.POST("/emby/accounts/:id/full-mount", fullMountEmbyAccountHandler(svc))
|
||||
admin.GET("/emby/mounts", listEmbyMountsHandler(svc))
|
||||
admin.POST("/emby/mounts", createEmbyMountsHandler(svc))
|
||||
admin.PUT("/emby/mounts/:id", updateEmbyMountHandler(svc))
|
||||
admin.DELETE("/emby/mounts/:id", deleteEmbyMountHandler(svc))
|
||||
|
||||
admin.GET("/strm/accounts", listStrmAccountsHandler(svc))
|
||||
admin.POST("/strm/accounts", createStrmAccountHandler(svc))
|
||||
admin.PUT("/strm/accounts/:id", updateStrmAccountHandler(svc))
|
||||
|
||||
+12
-12
@@ -68,13 +68,13 @@ func listLibrarySeriesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
ctx := c.Request.Context()
|
||||
// 远程剧集库:远程 Series 映射为系列卡片。
|
||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(libID) {
|
||||
acctID, remoteID, _ := service.DecodeEmbyRemoteID(libID)
|
||||
acct := svc.EmbyRemote.AccountByID(ctx, acctID)
|
||||
if acct == nil {
|
||||
mountID, remoteID, _ := service.DecodeEmbyRemoteID(libID)
|
||||
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
|
||||
if mount == nil || acct == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
cards, err := svc.EmbyRemote.RemoteSeriesCards(ctx, acct, remoteID)
|
||||
cards, err := svc.EmbyRemote.RemoteSeriesCards(ctx, mount, acct, remoteID)
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
@@ -159,13 +159,13 @@ func listLibrarySeriesEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
ctx := c.Request.Context()
|
||||
// 远程系列 key(伪装系列 ID):转发远程该系列全部剧集。
|
||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(key) {
|
||||
acctID, remoteSeriesID, _ := service.DecodeEmbyRemoteID(key)
|
||||
acct := svc.EmbyRemote.AccountByID(ctx, acctID)
|
||||
if acct == nil {
|
||||
mountID, remoteSeriesID, _ := service.DecodeEmbyRemoteID(key)
|
||||
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
|
||||
if mount == nil || acct == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
items, err := svc.EmbyRemote.RemoteEpisodes(ctx, acct, remoteSeriesID)
|
||||
items, err := svc.EmbyRemote.RemoteEpisodes(ctx, mount, acct, remoteSeriesID)
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
@@ -201,13 +201,13 @@ func listMediaEpisodesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
ctx := c.Request.Context()
|
||||
// 远程条目:单集→同系列集列表;系列/季/文件夹→子集;电影→自身单条。
|
||||
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
|
||||
}
|
||||
items, err := svc.EmbyRemote.RemoteEpisodes(ctx, acct, remoteID)
|
||||
items, err := svc.EmbyRemote.RemoteEpisodes(ctx, mount, acct, remoteID)
|
||||
if err != nil {
|
||||
writeInternalOrCanceled(c, err)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user