mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +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
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Emby 媒体库挂载模型。
|
||||
//
|
||||
// 远程 Emby 账号(StrmAccount.Provider = emby_remote)只是一个服务器连接;
|
||||
// 「挂载」才决定把该服务器的哪个媒体库(View)暴露到本项目的媒体库中。
|
||||
// 这样同一个 Emby 服务器可以按库选择挂载,且每个挂载独立控制是否由 MMTL
|
||||
// 代理播放流量。
|
||||
package model
|
||||
|
||||
// EmbyMount 是远程 Emby 服务器上一个媒体库(View)的挂载配置。
|
||||
type EmbyMount struct {
|
||||
Base
|
||||
AccountID string `gorm:"size:36;index" json:"account_id"` // StrmAccount.ID(provider=emby_remote)
|
||||
RemoteViewID string `gorm:"size:128" json:"remote_view_id"` // 远程 Emby 的 View Id
|
||||
RemoteViewName string `gorm:"size:255" json:"remote_view_name"` // 远程媒体库原名(展示冗余)
|
||||
CollectionType string `gorm:"size:32" json:"collection_type"` // movies / tvshows / music ...
|
||||
Name string `gorm:"size:255" json:"name,omitempty"` // 覆盖显示名(可选,默认「账号 · 库名」)
|
||||
ProxyPlay bool `gorm:"default:false" json:"proxy_play"` // 该挂载播放流量是否经 MMTL 反向代理
|
||||
Enabled bool `gorm:"default:true" json:"enabled"` // 是否在媒体库中展示
|
||||
}
|
||||
@@ -58,5 +58,6 @@ func AllModels() []interface{} {
|
||||
&StrmUploadTask{},
|
||||
&StrmDirCache{},
|
||||
&ScrapeTask{},
|
||||
&EmbyMount{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
// EmbyMountRepository 持久化远程 Emby 媒体库挂载。
|
||||
type EmbyMountRepository struct{ db *gorm.DB }
|
||||
|
||||
func (r *EmbyMountRepository) Create(ctx context.Context, m *model.EmbyMount) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Create(m).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *EmbyMountRepository) CreateInBatches(ctx context.Context, mounts []*model.EmbyMount, batchSize int) error {
|
||||
if len(mounts) == 0 {
|
||||
return nil
|
||||
}
|
||||
if batchSize <= 0 {
|
||||
batchSize = 50
|
||||
}
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).CreateInBatches(mounts, batchSize).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *EmbyMountRepository) FindByID(ctx context.Context, id string) (*model.EmbyMount, error) {
|
||||
var m model.EmbyMount
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&m).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (r *EmbyMountRepository) List(ctx context.Context) ([]model.EmbyMount, error) {
|
||||
var rows []model.EmbyMount
|
||||
err := r.db.WithContext(ctx).Order("created_at desc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *EmbyMountRepository) ListByAccountID(ctx context.Context, accountID string) ([]model.EmbyMount, error) {
|
||||
var rows []model.EmbyMount
|
||||
err := r.db.WithContext(ctx).Where("account_id = ?", accountID).Order("created_at asc").Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (r *EmbyMountRepository) CountByAccountID(ctx context.Context, accountID string) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(&model.EmbyMount{}).Where("account_id = ?", accountID).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *EmbyMountRepository) Update(ctx context.Context, m *model.EmbyMount) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Model(&model.EmbyMount{}).Where("id = ?", m.ID).Updates(map[string]any{
|
||||
"name": m.Name,
|
||||
"proxy_play": m.ProxyPlay,
|
||||
"enabled": m.Enabled,
|
||||
"remote_view_id": m.RemoteViewID,
|
||||
"remote_view_name": m.RemoteViewName,
|
||||
"collection_type": m.CollectionType,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (r *EmbyMountRepository) Delete(ctx context.Context, id string) error {
|
||||
return withSQLiteBusyRetry(ctx, func() error {
|
||||
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.EmbyMount{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteByAccountID 删除账号下全部挂载(删除账号时级联清理)。
|
||||
func (r *EmbyMountRepository) DeleteByAccountID(ctx context.Context, accountID string) (int64, error) {
|
||||
var count int64
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Where("account_id = ?", accountID).Delete(&model.EmbyMount{})
|
||||
count = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
@@ -33,6 +33,7 @@ type Container struct {
|
||||
StrmUpload *StrmUploadTaskRepository
|
||||
StrmDirCache *StrmDirCacheRepository
|
||||
ScrapeTask *ScrapeTaskRepository
|
||||
EmbyMount *EmbyMountRepository
|
||||
}
|
||||
|
||||
// New 将每个 repository 连接到单个 *gorm.DB。
|
||||
@@ -62,5 +63,6 @@ func New(db *gorm.DB) *Container {
|
||||
StrmUpload: &StrmUploadTaskRepository{db: db},
|
||||
StrmDirCache: &StrmDirCacheRepository{db: db},
|
||||
ScrapeTask: &ScrapeTaskRepository{db: db},
|
||||
EmbyMount: &EmbyMountRepository{db: db},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,11 +58,23 @@ func newEmby(cfg map[string]any, client *http.Client) Provider {
|
||||
client: client,
|
||||
}
|
||||
if p.client == nil {
|
||||
p.client = http.DefaultClient
|
||||
p.client = &http.Client{Transport: &embyUATransport{base: http.DefaultTransport}}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// embyUATransport 给远程 Emby 请求注入浏览器 UA(防 Cloudflare 风控拦截)。
|
||||
type embyUATransport struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *embyUATransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if strings.TrimSpace(req.Header.Get("User-Agent")) == "" {
|
||||
req.Header.Set("User-Agent", defaultUA)
|
||||
}
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
// embyBase normalizes the address so requests go to /emby/... endpoints.
|
||||
func (p *embyProvider) embyBase() string {
|
||||
base := strings.TrimRight(p.base, "/")
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (string, error) {
|
||||
// 远程 Emby 条目:直接返回远程图片绝对地址,由 ImageProxy 拉取透传。
|
||||
if e.remote != nil && IsEmbyRemoteID(id) {
|
||||
acctID, remoteID, _ := DecodeEmbyRemoteID(id)
|
||||
acct := e.remote.AccountByID(ctx, acctID)
|
||||
if acct == nil {
|
||||
mountID, remoteID, _ := DecodeEmbyRemoteID(id)
|
||||
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
|
||||
if mount == nil || acct == nil {
|
||||
return "", nil
|
||||
}
|
||||
return e.remote.RemoteImageURL(ctx, acct, remoteID, imageType)
|
||||
|
||||
@@ -148,12 +148,12 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
||||
if e.remote != nil {
|
||||
// 远程目录浏览:ParentId 带远程前缀 → 完整转发给远程 Emby 承接分页。
|
||||
if IsEmbyRemoteID(p.ParentID) {
|
||||
acctID, _, _ := DecodeEmbyRemoteID(p.ParentID)
|
||||
acct := e.remote.AccountByID(ctx, acctID)
|
||||
if acct == nil {
|
||||
mountID, _, _ := DecodeEmbyRemoteID(p.ParentID)
|
||||
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
|
||||
if mount == nil || acct == nil {
|
||||
return emptyItemsEnvelope(p.StartIndex), nil
|
||||
}
|
||||
return e.remote.RemoteItems(ctx, acct, p)
|
||||
return e.remote.RemoteItems(ctx, mount, acct, p)
|
||||
}
|
||||
// 全局搜索:无 ParentId 且带搜索词 → 聚合本地 + 全部远程。
|
||||
if p.ParentID == "" && p.SearchTerm != "" {
|
||||
@@ -250,12 +250,23 @@ func (e *EmbyService) aggregatedSearch(ctx context.Context, p ItemsParams) (map[
|
||||
type remoteResult struct {
|
||||
items []any
|
||||
}
|
||||
accounts, aerr := e.remote.ListAccounts(ctx)
|
||||
results := make([]remoteResult, 0, len(accounts))
|
||||
mounts, aerr := e.remote.ListMounts(ctx)
|
||||
results := make([]remoteResult, 0, len(mounts))
|
||||
if aerr == nil {
|
||||
for i := range accounts {
|
||||
acct := &accounts[i]
|
||||
remote, rerr := e.remote.RemoteItems(ctx, acct, p)
|
||||
for i := range mounts {
|
||||
m := mounts[i]
|
||||
if !m.Enabled {
|
||||
continue
|
||||
}
|
||||
acct := e.remote.AccountByID(ctx, m.AccountID)
|
||||
if acct == nil {
|
||||
continue
|
||||
}
|
||||
// 按挂载逐个搜索:搜索结果归属明确(伪装 ID 正确),也天然只搜已
|
||||
// 挂载的媒体库。
|
||||
searchParams := p
|
||||
searchParams.ParentID = "" // RemoteSearchMount 内部设 ParentId
|
||||
remote, rerr := e.remote.RemoteSearchMount(ctx, &m, acct, p)
|
||||
if rerr != nil {
|
||||
if e.log != nil {
|
||||
e.log.Warn("remote emby search failed",
|
||||
|
||||
@@ -13,12 +13,12 @@ import (
|
||||
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
|
||||
// 远程 Emby 条目:不查本地库,直接向远程转发(保持远程最新元数据)。
|
||||
if e.remote != nil && IsEmbyRemoteID(mediaID) {
|
||||
acctID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||
acct := e.remote.AccountByID(ctx, acctID)
|
||||
if acct == nil {
|
||||
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
|
||||
if mount == nil || acct == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return e.remote.RemoteItem(ctx, acct, remoteID)
|
||||
return e.remote.RemoteItem(ctx, mount, acct, remoteID)
|
||||
}
|
||||
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil {
|
||||
return nil, err
|
||||
@@ -86,12 +86,12 @@ func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string,
|
||||
limit = 20
|
||||
}
|
||||
if e.remote != nil && IsEmbyRemoteID(parentID) {
|
||||
acctID, remoteParent, _ := DecodeEmbyRemoteID(parentID)
|
||||
acct := e.remote.AccountByID(ctx, acctID)
|
||||
if acct == nil {
|
||||
mountID, remoteParent, _ := DecodeEmbyRemoteID(parentID)
|
||||
mount, acct, _ := e.remote.ResolveMount(ctx, mountID)
|
||||
if mount == nil || acct == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return e.remote.RemoteLatest(ctx, acct, remoteParent, limit)
|
||||
return e.remote.RemoteLatest(ctx, mount, acct, remoteParent, limit)
|
||||
}
|
||||
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
|
||||
var cached embyLatestCacheValue
|
||||
|
||||
@@ -19,19 +19,19 @@ import (
|
||||
// 播放地址指向远程(直连)还是 MMTL 本地代理端点。
|
||||
func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string) (map[string]any, error) {
|
||||
if e.remote != nil && IsEmbyRemoteID(mediaID) {
|
||||
acctID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||
acct := e.remote.AccountByID(ctx, acctID)
|
||||
if acct == nil {
|
||||
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
|
||||
if err != nil {
|
||||
return nil, ErrEmbyRemoteNotFound
|
||||
}
|
||||
out, err := e.remote.RemotePlaybackInfo(ctx, acct, remoteID, userID)
|
||||
out, err := e.remote.RemotePlaybackInfo(ctx, mount, acct, remoteID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
return nil, ErrEmbyRemoteNotFound
|
||||
}
|
||||
out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", acctID, time.Now().Unix())
|
||||
out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", mountID, time.Now().Unix())
|
||||
return out, nil
|
||||
}
|
||||
m, err := e.playableMedia(ctx, mediaID, userID)
|
||||
@@ -56,48 +56,48 @@ func (e *EmbyService) RemoteAccountByID(ctx context.Context, accountID string) *
|
||||
}
|
||||
|
||||
// ProxyRemoteVideoStream 反向代理远程 Emby 视频流(保留 Range)。
|
||||
func (e *EmbyService) ProxyRemoteVideoStream(ctx context.Context, w http.ResponseWriter, r *http.Request, accountID, remoteID string) error {
|
||||
func (e *EmbyService) ProxyRemoteVideoStream(ctx context.Context, w http.ResponseWriter, r *http.Request, mountID, remoteID string) error {
|
||||
if e == nil || e.remote == nil {
|
||||
return ErrEmbyRemoteNotFound
|
||||
}
|
||||
acct := e.remote.AccountByID(ctx, accountID)
|
||||
if acct == nil {
|
||||
_, acct, err := e.remote.ResolveMount(ctx, mountID)
|
||||
if err != nil {
|
||||
return ErrEmbyRemoteNotFound
|
||||
}
|
||||
return e.remote.ProxyVideoStream(ctx, w, r, acct, remoteID)
|
||||
}
|
||||
|
||||
// ProxyRemoteSubtitle 反向代理远程 Emby 字幕流。
|
||||
func (e *EmbyService) ProxyRemoteSubtitle(ctx context.Context, w http.ResponseWriter, r *http.Request, accountID, remoteID, index string) error {
|
||||
func (e *EmbyService) ProxyRemoteSubtitle(ctx context.Context, w http.ResponseWriter, r *http.Request, mountID, remoteID, index string) error {
|
||||
if e == nil || e.remote == nil {
|
||||
return ErrSubtitleNotFound
|
||||
}
|
||||
acct := e.remote.AccountByID(ctx, accountID)
|
||||
if acct == nil {
|
||||
_, acct, err := e.remote.ResolveMount(ctx, mountID)
|
||||
if err != nil {
|
||||
return ErrSubtitleNotFound
|
||||
}
|
||||
return e.remote.ProxySubtitle(ctx, w, r, acct, remoteID, index)
|
||||
}
|
||||
|
||||
// ProxyRemoteSetPlayed 把已看/未看状态透传到远程 Emby。
|
||||
func (e *EmbyService) ProxyRemoteSetPlayed(ctx context.Context, accountID, remoteID string, played bool) error {
|
||||
func (e *EmbyService) ProxyRemoteSetPlayed(ctx context.Context, mountID, remoteID string, played bool) error {
|
||||
if e == nil || e.remote == nil {
|
||||
return ErrEmbyRemoteNotFound
|
||||
}
|
||||
acct := e.remote.AccountByID(ctx, accountID)
|
||||
if acct == nil {
|
||||
_, acct, err := e.remote.ResolveMount(ctx, mountID)
|
||||
if err != nil {
|
||||
return ErrEmbyRemoteNotFound
|
||||
}
|
||||
return e.remote.ProxySetPlayed(ctx, acct, remoteID, played)
|
||||
}
|
||||
|
||||
// ProxyRemoteSetFavorite 把收藏/取消收藏状态透传到远程 Emby。
|
||||
func (e *EmbyService) ProxyRemoteSetFavorite(ctx context.Context, accountID, remoteID string, favorite bool) error {
|
||||
func (e *EmbyService) ProxyRemoteSetFavorite(ctx context.Context, mountID, remoteID string, favorite bool) error {
|
||||
if e == nil || e.remote == nil {
|
||||
return ErrEmbyRemoteNotFound
|
||||
}
|
||||
acct := e.remote.AccountByID(ctx, accountID)
|
||||
if acct == nil {
|
||||
_, acct, err := e.remote.ResolveMount(ctx, mountID)
|
||||
if err != nil {
|
||||
return ErrEmbyRemoteNotFound
|
||||
}
|
||||
return e.remote.ProxySetFavorite(ctx, acct, remoteID, favorite)
|
||||
|
||||
+222
-21
@@ -36,6 +36,22 @@ import (
|
||||
// embyRemoteHTTPTimeout 远程 Emby 常规 API 请求超时(流式代理不在此列)。
|
||||
const embyRemoteHTTPTimeout = 15 * time.Second
|
||||
|
||||
// embyRemoteUA 桌面浏览器 UA:远程 Emby 前方若有 Cloudflare/WAF 会拦截
|
||||
// Go-http-client 等非浏览器 UA(403 error code: 1010),必须伪装浏览器。
|
||||
const embyRemoteUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"
|
||||
|
||||
// embyRemoteTransport 统一给远程请求注入浏览器 UA。
|
||||
type embyRemoteTransport struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *embyRemoteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if strings.TrimSpace(req.Header.Get("User-Agent")) == "" {
|
||||
req.Header.Set("User-Agent", embyRemoteUA)
|
||||
}
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
// EmbyRemoteConfig 是一个远程 Emby 账号的解密配置。
|
||||
type EmbyRemoteConfig struct {
|
||||
BaseURL string // http://host:8096(无需 /emby 后缀)
|
||||
@@ -63,7 +79,8 @@ func NewEmbyRemoteService(cfg *config.Config, log *zap.Logger, repo *repository.
|
||||
repo: repo,
|
||||
crypto: crypto,
|
||||
http: &http.Client{
|
||||
Timeout: embyRemoteHTTPTimeout,
|
||||
Timeout: embyRemoteHTTPTimeout,
|
||||
Transport: &embyRemoteTransport{base: http.DefaultTransport},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -98,6 +115,157 @@ func (r *EmbyRemoteService) AccountByID(ctx context.Context, id string) *model.S
|
||||
return acct
|
||||
}
|
||||
|
||||
// ─── 媒体库挂载管理 ─────────────────────────────────────────────────────────────
|
||||
|
||||
// ListMounts 返回全部挂载。
|
||||
func (r *EmbyRemoteService) ListMounts(ctx context.Context) ([]model.EmbyMount, error) {
|
||||
return r.repo.EmbyMount.List(ctx)
|
||||
}
|
||||
|
||||
// ListMountsByAccount 返回指定账号的挂载。
|
||||
func (r *EmbyRemoteService) ListMountsByAccount(ctx context.Context, accountID string) ([]model.EmbyMount, error) {
|
||||
return r.repo.EmbyMount.ListByAccountID(ctx, accountID)
|
||||
}
|
||||
|
||||
// MountByID 按 ID 查挂载。
|
||||
func (r *EmbyRemoteService) MountByID(ctx context.Context, id string) (*model.EmbyMount, error) {
|
||||
return r.repo.EmbyMount.FindByID(ctx, id)
|
||||
}
|
||||
|
||||
// CreateMount 创建一个挂载(校验账号类型与远程 View 编号)。
|
||||
func (r *EmbyRemoteService) CreateMount(ctx context.Context, m *model.EmbyMount) (*model.EmbyMount, error) {
|
||||
if strings.TrimSpace(m.AccountID) == "" || strings.TrimSpace(m.RemoteViewID) == "" {
|
||||
return nil, errors.New("缺少账号或远程媒体库")
|
||||
}
|
||||
if r.AccountByID(ctx, m.AccountID) == nil {
|
||||
return nil, errors.New("远程 Emby 账号不存在或已禁用")
|
||||
}
|
||||
if err := r.repo.EmbyMount.Create(ctx, m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// CreateMounts 批量创建挂载(幂等:已存在的远程库自动跳过)。
|
||||
func (r *EmbyRemoteService) CreateMounts(ctx context.Context, mounts []*model.EmbyMount) (int, error) {
|
||||
if len(mounts) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
existing, err := r.repo.EmbyMount.ListByAccountID(ctx, mounts[0].AccountID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
have := make(map[string]bool, len(existing))
|
||||
for _, e := range existing {
|
||||
have[e.RemoteViewID] = true
|
||||
}
|
||||
fresh := make([]*model.EmbyMount, 0, len(mounts))
|
||||
for _, m := range mounts {
|
||||
if m == nil || have[m.RemoteViewID] {
|
||||
continue
|
||||
}
|
||||
fresh = append(fresh, m)
|
||||
}
|
||||
if len(fresh) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if err := r.repo.EmbyMount.CreateInBatches(ctx, fresh, 50); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(fresh), nil
|
||||
}
|
||||
|
||||
// UpdateMount 更新挂载(名称 / 代理 / 启用)。
|
||||
func (r *EmbyRemoteService) UpdateMount(ctx context.Context, id string, m *model.EmbyMount) (*model.EmbyMount, error) {
|
||||
existing, err := r.repo.EmbyMount.FindByID(ctx, id)
|
||||
if err != nil || existing == nil {
|
||||
return nil, errNotFoundOr(err, "挂载不存在")
|
||||
}
|
||||
existing.Name = strings.TrimSpace(m.Name)
|
||||
existing.ProxyPlay = m.ProxyPlay
|
||||
existing.Enabled = m.Enabled
|
||||
if err := r.repo.EmbyMount.Update(ctx, existing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
// DeleteMount 删除挂载。
|
||||
func (r *EmbyRemoteService) DeleteMount(ctx context.Context, id string) error {
|
||||
return r.repo.EmbyMount.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// FullMountAccount 把账号的全部远程媒体库(View)挂载进来(幂等,已存在跳过)。
|
||||
func (r *EmbyRemoteService) FullMountAccount(ctx context.Context, acct *model.StrmAccount, proxyPlayDefault bool) (int, error) {
|
||||
views, err := r.RemoteViews(ctx, acct)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
mounts := make([]*model.EmbyMount, 0, len(views))
|
||||
for _, v := range views {
|
||||
viewID := remoteItemString(v, "Id")
|
||||
if viewID == "" {
|
||||
continue
|
||||
}
|
||||
mounts = append(mounts, &model.EmbyMount{
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: viewID,
|
||||
RemoteViewName: remoteItemString(v, "Name"),
|
||||
CollectionType: remoteItemString(v, "CollectionType"),
|
||||
ProxyPlay: proxyPlayDefault,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
return r.CreateMounts(ctx, mounts)
|
||||
}
|
||||
|
||||
// ResolveMount 按伪装 ID 的第一段(挂载 ID)解析挂载与其所属账号。
|
||||
// 远程条目/媒体库的伪装 ID 格式:embyremote~{mountID}~{remoteID}。
|
||||
func (r *EmbyRemoteService) ResolveMount(ctx context.Context, mountID string) (*model.EmbyMount, *model.StrmAccount, error) {
|
||||
mount, err := r.repo.EmbyMount.FindByID(ctx, mountID)
|
||||
if err != nil || mount == nil || !mount.Enabled {
|
||||
return nil, nil, errors.New("挂载不存在或已禁用")
|
||||
}
|
||||
acct := r.AccountByID(ctx, mount.AccountID)
|
||||
if acct == nil {
|
||||
return nil, nil, errors.New("远程 Emby 账号不存在或已禁用")
|
||||
}
|
||||
return mount, acct, nil
|
||||
}
|
||||
|
||||
// AutoSeedMounts 兼容迁移:已有 emby_remote 账号但没有任何挂载时,自动把
|
||||
// 其全部媒体库挂载进来(代理沿用账号旧配置),保证旧部署升级后媒体库不消失。
|
||||
// 幂等:每个账号只在挂载数为 0 时执行一次。
|
||||
func (r *EmbyRemoteService) AutoSeedMounts(ctx context.Context) {
|
||||
accounts, err := r.ListAccounts(ctx)
|
||||
if err != nil || len(accounts) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range accounts {
|
||||
acct := &accounts[i]
|
||||
count, err := r.repo.EmbyMount.CountByAccountID(ctx, acct.ID)
|
||||
if err != nil || count > 0 {
|
||||
continue
|
||||
}
|
||||
cfg, cfgErr := r.configOf(acct)
|
||||
if cfgErr != nil {
|
||||
continue
|
||||
}
|
||||
n, seedErr := r.FullMountAccount(ctx, acct, cfg.ProxyPlay)
|
||||
if seedErr != nil {
|
||||
if r.log != nil {
|
||||
r.log.Warn("auto-seed emby mounts failed",
|
||||
zap.String("account", acct.Name), zap.Error(seedErr))
|
||||
}
|
||||
} else if n > 0 {
|
||||
if r.log != nil {
|
||||
r.log.Info("auto-seeded emby mounts",
|
||||
zap.String("account", acct.Name), zap.Int("mounts", n))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// configOf 解密账号配置。
|
||||
func (r *EmbyRemoteService) configOf(acct *model.StrmAccount) (*EmbyRemoteConfig, error) {
|
||||
raw := map[string]string{}
|
||||
@@ -308,7 +476,7 @@ func (r *EmbyRemoteService) remoteUserID(cfg *EmbyRemoteConfig) string {
|
||||
|
||||
// RemoteItems 向远程 Emby 转发 /Items 浏览/搜索请求,返回重写后的响应载荷。
|
||||
// p 的分页/排序/过滤参数原样转发,分页语义完全由远程承接。
|
||||
func (r *EmbyRemoteService) RemoteItems(ctx context.Context, acct *model.StrmAccount, p ItemsParams) (map[string]any, error) {
|
||||
func (r *EmbyRemoteService) RemoteItems(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, p ItemsParams) (map[string]any, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -347,12 +515,46 @@ func (r *EmbyRemoteService) RemoteItems(ctx context.Context, acct *model.StrmAcc
|
||||
if out == nil {
|
||||
out = map[string]any{"Items": []any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}
|
||||
}
|
||||
RewriteEmbyRemoteIDs(out, acct.ID)
|
||||
RewriteEmbyRemoteIDs(out, mount.ID)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RemoteSearchMount 对单个挂载的媒体库执行全局搜索(ParentId=挂载的远程库,
|
||||
// Recursive 返回库内全部命中),结果归属明确可直接伪装。
|
||||
func (r *EmbyRemoteService) RemoteSearchMount(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, p ItemsParams) (map[string]any, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("ParentId", mount.RemoteViewID)
|
||||
q.Set("Recursive", "true")
|
||||
q.Set("SearchTerm", p.SearchTerm)
|
||||
q.Set("UserId", r.remoteUserID(cfg))
|
||||
q.Set("Limit", strconv.Itoa(p.Limit))
|
||||
q.Set("StartIndex", strconv.Itoa(p.StartIndex))
|
||||
if p.SortBy != "" {
|
||||
q.Set("SortBy", p.SortBy)
|
||||
}
|
||||
if p.SortOrder != "" {
|
||||
q.Set("SortOrder", p.SortOrder)
|
||||
}
|
||||
if len(p.IncludeItemTypes) > 0 {
|
||||
q.Set("IncludeItemTypes", strings.Join(p.IncludeItemTypes, ","))
|
||||
}
|
||||
var out map[string]any
|
||||
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = map[string]any{"Items": []any{}, "TotalRecordCount": 0}
|
||||
}
|
||||
RewriteEmbyRemoteIDs(out, mount.ID)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RemoteItem 拉取远程单条目详情(含响应的重写)。
|
||||
func (r *EmbyRemoteService) RemoteItem(ctx context.Context, acct *model.StrmAccount, remoteID string) (map[string]any, error) {
|
||||
func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (map[string]any, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -362,12 +564,12 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, acct *model.StrmAcco
|
||||
if err := r.doGet(ctx, acct, cfg, path, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
RewriteEmbyRemoteIDs(out, acct.ID)
|
||||
RewriteEmbyRemoteIDs(out, mount.ID)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RemoteLatest 拉取远程「最近添加」(用于 /Items/Latest 聚合)。
|
||||
func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, acct *model.StrmAccount, parentID string, limit int) ([]map[string]any, error) {
|
||||
func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, parentID string, limit int) ([]map[string]any, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -381,15 +583,14 @@ func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, acct *model.StrmAc
|
||||
if err := r.doGet(ctx, acct, cfg, path, q, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
RewriteEmbyRemoteIDs(out, acct.ID)
|
||||
RewriteEmbyRemoteIDs(out, mount.ID)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RemotePlaybackInfo 拉取远程 PlaybackInfo,并按 proxy_play 配置重写播放 URL:
|
||||
// - 不代理:MediaSource 的 DirectStreamUrl / TranscodingUrl / 字幕 DeliveryUrl
|
||||
// 指向远程绝对地址(播放字节不过 MMTL);
|
||||
// - 代理:指向 MMTL 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。
|
||||
func (r *EmbyRemoteService) RemotePlaybackInfo(ctx context.Context, acct *model.StrmAccount, remoteID, userID string) (map[string]any, error) {
|
||||
// RemotePlaybackInfo 拉取远程 PlaybackInfo,并按挂载的 proxy_play 配置重写
|
||||
// 播放 URL:不代理=指向远程绝对地址(播放字节不过 MMTL);代理=指向 MMTL
|
||||
// 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。
|
||||
func (r *EmbyRemoteService) RemotePlaybackInfo(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID, userID string) (map[string]any, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -400,17 +601,17 @@ func (r *EmbyRemoteService) RemotePlaybackInfo(ctx context.Context, acct *model.
|
||||
if err := r.doGet(ctx, acct, cfg, path, q, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
RewriteEmbyRemoteIDs(out, acct.ID)
|
||||
r.rewritePlayURLs(out, acct, cfg, remoteID)
|
||||
RewriteEmbyRemoteIDs(out, mount.ID)
|
||||
r.rewritePlayURLs(out, mount, cfg, remoteID)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// rewritePlayURLs 按代理模式重写载荷内 MediaSources 的播放地址。
|
||||
// rewritePlayURLs 按挂载代理模式重写载荷内 MediaSources 的播放地址。
|
||||
// 远程 Emby 的 PlaybackInfo 通常不返回 DirectStreamUrl(客户端靠它拼
|
||||
// /Videos/{Id}/stream),因此这里总是强制构造播放地址,完全由 MMTL 掌控
|
||||
// 直连(远程绝对 URL)或代理(本地 /Videos/{encoded})的最终去向。
|
||||
func (r *EmbyRemoteService) rewritePlayURLs(value any, acct *model.StrmAccount, cfg *EmbyRemoteConfig, remoteID string) {
|
||||
encoded := EncodeEmbyRemoteID(acct.ID, remoteID)
|
||||
func (r *EmbyRemoteService) rewritePlayURLs(value any, mount *model.EmbyMount, cfg *EmbyRemoteConfig, remoteID string) {
|
||||
encoded := EncodeEmbyRemoteID(mount.ID, remoteID)
|
||||
sources := collectMediaSources(value)
|
||||
if sources == nil {
|
||||
return
|
||||
@@ -418,7 +619,7 @@ func (r *EmbyRemoteService) rewritePlayURLs(value any, acct *model.StrmAccount,
|
||||
for _, src := range sources {
|
||||
mediaSourceID, _ := src["Id"].(string)
|
||||
var streamPath, subtitlePlayURL string
|
||||
if cfg.ProxyPlay {
|
||||
if mount.ProxyPlay {
|
||||
streamPath = "/Videos/" + url.PathEscape(encoded) + "/stream"
|
||||
subtitlePlayURL = "/Videos/" + url.PathEscape(encoded)
|
||||
} else {
|
||||
@@ -531,9 +732,9 @@ func (r *EmbyRemoteService) ProxyVideoStream(ctx context.Context, w http.Respons
|
||||
if mediaSourceID := strings.TrimSpace(req.URL.Query().Get("MediaSourceId")); mediaSourceID != "" {
|
||||
q.Set("MediaSourceId", mediaSourceID)
|
||||
}
|
||||
if static := strings.TrimSpace(req.URL.Query().Get("Static")); static != "" {
|
||||
q.Set("Static", static)
|
||||
}
|
||||
// 代理是纯 byte 中继:始终要求远程原文件直连(Static=true 阻止远程触发
|
||||
// ffmpeg 转码调度——远程转码可能未配置/故障,导致整个代理 500)。
|
||||
q.Set("Static", "true")
|
||||
q.Set("api_key", cfg.Token)
|
||||
if encoded := q.Encode(); encoded != "" {
|
||||
endpoint += "?" + encoded
|
||||
|
||||
@@ -19,24 +19,38 @@ import (
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
// RemoteLibraryView 是一个网页端可见的远程媒体库(对应远程 Emby 一个 View)。
|
||||
// RemoteLibraryView 是一个网页端可见的远程媒体库(对应一个挂载的远程 View)。
|
||||
type RemoteLibraryView struct {
|
||||
Library model.Library
|
||||
MountID string
|
||||
AccountID string
|
||||
RemoteID string
|
||||
CollectionType string
|
||||
AccountName string
|
||||
}
|
||||
|
||||
// RemoteLibraries 把所有启用远程账号的 View 映射为网页媒体库列表。
|
||||
// RemoteLibraries 把所有启用挂载的远程媒体库映射为网页媒体库列表
|
||||
// (只有显式挂载的库才出现在本项目媒体库中)。
|
||||
func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibraryView, error) {
|
||||
accounts, err := r.ListAccounts(ctx)
|
||||
if err != nil || len(accounts) == 0 {
|
||||
mounts, err := r.ListMounts(ctx)
|
||||
if err != nil || len(mounts) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]RemoteLibraryView, 0, len(accounts)*2)
|
||||
for i := range accounts {
|
||||
acct := &accounts[i]
|
||||
// 按账号分组,每账号拉一次 Views 做匹配。
|
||||
byAccount := map[string][]*model.EmbyMount{}
|
||||
for i := range mounts {
|
||||
m := mounts[i]
|
||||
if !m.Enabled {
|
||||
continue
|
||||
}
|
||||
byAccount[m.AccountID] = append(byAccount[m.AccountID], &mounts[i])
|
||||
}
|
||||
out := make([]RemoteLibraryView, 0, len(mounts))
|
||||
for accountID, accountMounts := range byAccount {
|
||||
acct := r.AccountByID(ctx, accountID)
|
||||
if acct == nil {
|
||||
continue
|
||||
}
|
||||
cfg, cfgErr := r.configOf(acct)
|
||||
if cfgErr != nil {
|
||||
continue
|
||||
@@ -49,16 +63,25 @@ func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibrar
|
||||
}
|
||||
continue
|
||||
}
|
||||
viewByName := map[string]map[string]any{}
|
||||
for _, v := range views {
|
||||
lib := r.mapRemoteViewToLibrary(acct, cfg, v)
|
||||
viewByName[remoteItemString(v, "Id")] = v
|
||||
}
|
||||
for _, mount := range accountMounts {
|
||||
v, ok := viewByName[mount.RemoteViewID]
|
||||
if !ok {
|
||||
continue // 远程已删除该媒体库
|
||||
}
|
||||
lib := r.mapRemoteMountToLibrary(mount, acct, cfg, v)
|
||||
if lib == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, RemoteLibraryView{
|
||||
Library: *lib,
|
||||
MountID: mount.ID,
|
||||
AccountID: acct.ID,
|
||||
RemoteID: remoteItemString(v, "Id"),
|
||||
CollectionType: remoteItemString(v, "CollectionType"),
|
||||
RemoteID: mount.RemoteViewID,
|
||||
CollectionType: mount.CollectionType,
|
||||
AccountName: acct.Name,
|
||||
})
|
||||
}
|
||||
@@ -67,13 +90,13 @@ func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibrar
|
||||
}
|
||||
|
||||
// RemoteLibraryByID 按伪装 ID 查远程库视图(详情接口用)。
|
||||
func (r *EmbyRemoteService) RemoteLibraryByID(ctx context.Context, accountID, remoteID string) (*RemoteLibraryView, error) {
|
||||
func (r *EmbyRemoteService) RemoteLibraryByID(ctx context.Context, mountID, remoteViewID string) (*RemoteLibraryView, error) {
|
||||
views, err := r.RemoteLibraries(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, v := range views {
|
||||
if v.AccountID == accountID && v.RemoteID == remoteID {
|
||||
if v.MountID == mountID && v.RemoteID == remoteViewID {
|
||||
cp := v
|
||||
return &cp, nil
|
||||
}
|
||||
@@ -81,37 +104,49 @@ func (r *EmbyRemoteService) RemoteLibraryByID(ctx context.Context, accountID, re
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// mapRemoteViewToLibrary 把远程 View item 映射为网页库结构。
|
||||
func (r *EmbyRemoteService) mapRemoteViewToLibrary(acct *model.StrmAccount, cfg *EmbyRemoteConfig, item map[string]any) *model.Library {
|
||||
remoteID := remoteItemString(item, "Id")
|
||||
if remoteID == "" {
|
||||
// mapRemoteMountToLibrary 把挂载信息 + 远程 View item 映射为网页库结构。
|
||||
func (r *EmbyRemoteService) mapRemoteMountToLibrary(mount *model.EmbyMount, acct *model.StrmAccount, cfg *EmbyRemoteConfig, item map[string]any) *model.Library {
|
||||
if mount == nil {
|
||||
return nil
|
||||
}
|
||||
name := remoteItemString(item, "Name")
|
||||
name := strings.TrimSpace(mount.Name)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(remoteItemString(item, "Name"))
|
||||
}
|
||||
if name == "" {
|
||||
name = acct.Name
|
||||
} else if !strings.Contains(name, acct.Name) {
|
||||
name = acct.Name + " · " + name
|
||||
}
|
||||
libType := "movie"
|
||||
switch remoteItemString(item, "CollectionType") {
|
||||
switch mount.CollectionType {
|
||||
case "tvshows":
|
||||
libType = "tv"
|
||||
case "music":
|
||||
libType = "music"
|
||||
}
|
||||
return &model.Library{
|
||||
Base: model.Base{ID: EncodeEmbyRemoteID(acct.ID, remoteID)},
|
||||
Name: acct.Name + " · " + name,
|
||||
lib := &model.Library{
|
||||
Base: model.Base{ID: EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)},
|
||||
Name: name,
|
||||
Type: libType,
|
||||
CoverURL: r.remoteItemImageURL(cfg, remoteID, "Primary"),
|
||||
Enabled: true,
|
||||
SortOrder: 1000, // 远程库排在本地库之后
|
||||
}
|
||||
// 远程媒体库封面只有真实存在图片标签才下发。
|
||||
if remoteItemHasImageTag(item, "Primary") {
|
||||
lib.CoverURL = r.remoteItemImageURL(cfg, mount.RemoteViewID, "Primary")
|
||||
}
|
||||
return lib
|
||||
}
|
||||
|
||||
// MapRemoteItemToMedia 把远程 Emby item JSON 映射为本地 Media 结构。
|
||||
// poster/backdrop 只有在远程确实存在图片标签时才填 URL(避免对无图条目
|
||||
// 发出必失败的图片请求导致前端破图);剧集回退到系列海报(SeriesPrimaryImage)。
|
||||
func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, item map[string]any) model.Media {
|
||||
func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, cfg *EmbyRemoteConfig, item map[string]any) model.Media {
|
||||
encodeScope := acct.ID
|
||||
if mount != nil {
|
||||
encodeScope = mount.ID
|
||||
}
|
||||
remoteID := remoteItemString(item, "Id")
|
||||
// 条目可能已被 RewriteEmbyRemoteIDs 伪装(图片/嵌套 ID 需要原始远程 ID)。
|
||||
if _, rid, ok := DecodeEmbyRemoteID(remoteID); ok {
|
||||
@@ -122,7 +157,7 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, acct *mode
|
||||
seriesID = rid
|
||||
}
|
||||
media := model.Media{
|
||||
Base: model.Base{ID: EncodeEmbyRemoteID(acct.ID, remoteID)},
|
||||
Base: model.Base{ID: EncodeEmbyRemoteID(encodeScope, remoteID)},
|
||||
Title: remoteItemString(item, "Name"),
|
||||
OriginalName: remoteItemString(item, "OriginalTitle"),
|
||||
Overview: remoteItemString(item, "Overview"),
|
||||
@@ -178,7 +213,7 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, acct *mode
|
||||
}
|
||||
|
||||
// RemoteLibraryMedia 拉远程库直属条目(电影库=Movie,剧集库=Series),映射分页。
|
||||
func (r *EmbyRemoteService) RemoteLibraryMedia(ctx context.Context, acct *model.StrmAccount, remoteViewID string, itemTypes string, offset, limit int) ([]model.Media, int64, error) {
|
||||
func (r *EmbyRemoteService) RemoteLibraryMedia(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, itemTypes string, offset, limit int) ([]model.Media, int64, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@@ -202,14 +237,14 @@ func (r *EmbyRemoteService) RemoteLibraryMedia(ctx context.Context, acct *model.
|
||||
}
|
||||
items := make([]model.Media, 0, len(body.Items))
|
||||
for _, it := range body.Items {
|
||||
RewriteEmbyRemoteIDs(it, acct.ID) // 嵌套/关联 ID 一并伪装
|
||||
items = append(items, r.MapRemoteItemToMedia(ctx, acct, cfg, it))
|
||||
RewriteEmbyRemoteIDs(it, mount.ID) // 嵌套/关联 ID 一并伪装
|
||||
items = append(items, r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it))
|
||||
}
|
||||
return items, body.TotalRecordCount, nil
|
||||
}
|
||||
|
||||
// RemoteMediaDetail 拉远程单条目映射为 Media(网页详情页)。
|
||||
func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, acct *model.StrmAccount, remoteID string) (*model.Media, error) {
|
||||
func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) (*model.Media, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -220,15 +255,15 @@ func (r *EmbyRemoteService) RemoteMediaDetail(ctx context.Context, acct *model.S
|
||||
if err := r.doGet(ctx, acct, cfg, path, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
RewriteEmbyRemoteIDs(out, acct.ID)
|
||||
m := r.MapRemoteItemToMedia(ctx, acct, cfg, out)
|
||||
RewriteEmbyRemoteIDs(out, mount.ID)
|
||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, out)
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// RemoteEpisodes 拉远程条目下的集列表(Series/Season/Folder→子集;Episode→同系列;
|
||||
// Movie→自身单条),按季/集排序,与本地 ListMediaEpisodes 行为一致。
|
||||
func (r *EmbyRemoteService) RemoteEpisodes(ctx context.Context, acct *model.StrmAccount, remoteID string) ([]model.Media, error) {
|
||||
detail, err := r.RemoteMediaDetail(ctx, acct, remoteID)
|
||||
func (r *EmbyRemoteService) RemoteEpisodes(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteID string) ([]model.Media, error) {
|
||||
detail, err := r.RemoteMediaDetail(ctx, mount, acct, remoteID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -249,7 +284,7 @@ func (r *EmbyRemoteService) RemoteEpisodes(ctx context.Context, acct *model.Strm
|
||||
default: // Movie
|
||||
return []model.Media{*detail}, nil
|
||||
}
|
||||
rows, _, err := r.remoteEpisodesOf(ctx, acct, parentID)
|
||||
rows, _, err := r.remoteEpisodesOf(ctx, mount, acct, parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -265,7 +300,7 @@ func (r *EmbyRemoteService) RemoteEpisodes(ctx context.Context, acct *model.Strm
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, acct *model.StrmAccount, parentID string) ([]model.Media, int64, error) {
|
||||
func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, parentID string) ([]model.Media, int64, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
@@ -286,15 +321,15 @@ func (r *EmbyRemoteService) remoteEpisodesOf(ctx context.Context, acct *model.St
|
||||
}
|
||||
items := make([]model.Media, 0, len(body.Items))
|
||||
for _, it := range body.Items {
|
||||
RewriteEmbyRemoteIDs(it, acct.ID)
|
||||
m := r.MapRemoteItemToMedia(ctx, acct, cfg, it)
|
||||
RewriteEmbyRemoteIDs(it, mount.ID)
|
||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
||||
items = append(items, m)
|
||||
}
|
||||
return items, body.TotalRecordCount, nil
|
||||
}
|
||||
|
||||
// RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。
|
||||
func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, acct *model.StrmAccount, remoteViewID string) ([]SeriesCard, error) {
|
||||
func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string) ([]SeriesCard, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -314,8 +349,8 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, acct *model.S
|
||||
}
|
||||
cards := make([]SeriesCard, 0, len(body.Items))
|
||||
for _, it := range body.Items {
|
||||
RewriteEmbyRemoteIDs(it, acct.ID)
|
||||
m := r.MapRemoteItemToMedia(ctx, acct, cfg, it)
|
||||
RewriteEmbyRemoteIDs(it, mount.ID)
|
||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
||||
// 集数优先用递归条目数(ChildCount 只算直属 Season 文件夹数)。
|
||||
count := remoteItemInt(it, "RecursiveItemCount")
|
||||
if count == 0 {
|
||||
@@ -330,18 +365,18 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, acct *model.S
|
||||
}
|
||||
|
||||
// RemoteLatestCards 远程库最新条目(首页预览卡片),映射 SeriesCard。
|
||||
func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, acct *model.StrmAccount, remoteViewID string, limit int) ([]SeriesCard, error) {
|
||||
func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]SeriesCard, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := r.RemoteLatest(ctx, acct, remoteViewID, limit)
|
||||
items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cards := make([]SeriesCard, 0, len(items))
|
||||
for _, it := range items {
|
||||
m := r.MapRemoteItemToMedia(ctx, acct, cfg, it)
|
||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
||||
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0})
|
||||
}
|
||||
return cards, nil
|
||||
@@ -536,4 +571,17 @@ func remoteItemTypeOf(m *model.Media) string {
|
||||
return "Episode"
|
||||
}
|
||||
return "Movie"
|
||||
}
|
||||
}
|
||||
// ─── 供 handler 层使用的远程 View 条目取值(导出薄封装) ──────────────────────
|
||||
|
||||
// RemoteItemIDString 提取远程 View 条目的 Id。
|
||||
func RemoteItemIDString(item map[string]any) string { return remoteItemString(item, "Id") }
|
||||
|
||||
// RemoteItemNameString 提取远程 View 条目的 Name。
|
||||
func RemoteItemNameString(item map[string]any) string { return remoteItemString(item, "Name") }
|
||||
|
||||
// RemoteItemCollectionType 提取远程 View 条目的 CollectionType。
|
||||
func RemoteItemCollectionType(item map[string]any) string { return remoteItemString(item, "CollectionType") }
|
||||
|
||||
// RemoteItemChildCount 提取远程 View 条目的 ChildCount。
|
||||
func RemoteItemChildCount(item map[string]any) int { return remoteItemInt(item, "ChildCount") }
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -100,6 +100,11 @@ func (c *Container) Boot() {
|
||||
// 启动调度器定时任务
|
||||
c.Scheduler.Start(c.stopCtx)
|
||||
|
||||
// 远程 Emby 挂载兼容迁移:旧账号无挂载时自动全量挂载
|
||||
if c.EmbyRemote != nil {
|
||||
c.EmbyRemote.AutoSeedMounts(c.stopCtx)
|
||||
}
|
||||
|
||||
// STRM 元数据下载/上传队列与定时同步巡检
|
||||
if c.Strm != nil {
|
||||
c.Strm.Start(c.stopCtx)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { api } from './client'
|
||||
import type { EmbyMount, EmbyMountInput, RemoteEmbyView } from '../types/emby'
|
||||
|
||||
export const embyAPI = {
|
||||
listMounts: () => api.get<EmbyMount[]>('/admin/emby/mounts').then((r) => r.data),
|
||||
createMounts: (payload: EmbyMountInput) =>
|
||||
api.post<{ ok: boolean; created: number }>('/admin/emby/mounts', payload).then((r) => r.data),
|
||||
updateMount: (id: string, payload: { name?: string; proxy_play?: boolean; enabled?: boolean }) =>
|
||||
api.put<EmbyMount>(`/admin/emby/mounts/${id}`, payload).then((r) => r.data),
|
||||
deleteMount: (id: string) => api.delete(`/admin/emby/mounts/${id}`).then((r) => r.data),
|
||||
listAccountViews: (accountId: string) =>
|
||||
api.get<RemoteEmbyView[]>(`/admin/emby/accounts/${accountId}/views`).then((r) => r.data),
|
||||
fullMountAccount: (accountId: string, proxy: boolean) =>
|
||||
api
|
||||
.post<{ ok: boolean; created: number }>(`/admin/emby/accounts/${accountId}/full-mount`, null, {
|
||||
params: { proxy: proxy ? 1 : 0 },
|
||||
})
|
||||
.then((r) => r.data),
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const ProfileManagementPage = lazy(() =>
|
||||
import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })),
|
||||
)
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })))
|
||||
const EmbyMountPage = lazy(() => import('./pages/EmbyMountPage').then((m) => ({ default: m.EmbyMountPage })))
|
||||
const StrmManagePage = lazy(() => import('./pages/StrmManagePage').then((m) => ({ default: m.StrmManagePage })))
|
||||
const StrmDownloadQueuePage = lazy(() =>
|
||||
import('./pages/StrmQueuePage').then((m) => ({ default: m.StrmDownloadQueuePage })),
|
||||
@@ -62,6 +63,7 @@ export const appRoutes: AppRoute[] = [
|
||||
{ path: 'tools', element: <Navigate to="/files" replace /> },
|
||||
{ path: 'files', element: <FileManagerPage />, adminOnly: true },
|
||||
{ path: 'settings', element: <SettingsPage />, adminOnly: true },
|
||||
{ path: 'emby-mount', element: <EmbyMountPage />, adminOnly: true },
|
||||
{ path: 'strm', element: <StrmManagePage />, adminOnly: true },
|
||||
{ path: 'strm/downloads', element: <StrmDownloadQueuePage />, adminOnly: true },
|
||||
{ path: 'strm/uploads', element: <StrmUploadQueuePage />, adminOnly: true },
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Library,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Tv,
|
||||
Upload,
|
||||
User,
|
||||
Users,
|
||||
@@ -27,6 +28,8 @@ export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
|
||||
{ to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
|
||||
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
|
||||
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
|
||||
// Emby 挂载(设置区):远程 Emby 媒体库挂载
|
||||
{ to: '/emby-mount', label: 'Emby 挂载', icon: Tv, adminOnly: true },
|
||||
// STRM 管理(设置区):网盘目录生成 strm 与元数据下载/上传队列
|
||||
{ to: '/strm', label: 'STRM 管理', icon: FileOutput, adminOnly: true },
|
||||
{ to: '/strm/downloads', label: '下载队列', icon: Download, adminOnly: true },
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
Check,
|
||||
Globe,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Power,
|
||||
PowerOff,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
Tv,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { strmAPI } from '../api/strm'
|
||||
import { embyAPI } from '../api/emby'
|
||||
import type { StrmAccount } from '../types/strm'
|
||||
import type { EmbyMount, RemoteEmbyView } from '../types/emby'
|
||||
import { apiErrorMessage } from './StrmManagePage'
|
||||
import { confirmAction } from '../components/confirmAction'
|
||||
|
||||
const inputCls = 'input-base w-full'
|
||||
|
||||
// ─── 账号对话框(Emby 服务器连接) ─────────────────────────────────────────────
|
||||
|
||||
function AccountDialog({
|
||||
existing,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
existing: StrmAccount | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [name, setName] = useState(existing?.name ?? '')
|
||||
const [url, setUrl] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [enabled, setEnabled] = useState(existing?.enabled ?? true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const canSave = () => url !== '' && (token !== '' || password !== '')
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
const config: Record<string, string> = {
|
||||
...(url ? { url } : {}),
|
||||
...(username ? { username } : {}),
|
||||
...(password ? { password } : {}),
|
||||
...(token ? { token } : {}),
|
||||
}
|
||||
if (existing) {
|
||||
await strmAPI.updateAccount(existing.id, {
|
||||
name: name || existing.name,
|
||||
provider: existing.provider,
|
||||
enabled,
|
||||
config: Object.keys(config).length ? config : {},
|
||||
})
|
||||
toast.success('Emby 账号已更新')
|
||||
} else {
|
||||
if (!canSave()) {
|
||||
toast.error('请填写 Emby 地址与密码(或 API Key)')
|
||||
return
|
||||
}
|
||||
await strmAPI.createAccount({ name: name || 'Emby 服务器', provider: 'emby_remote', config })
|
||||
toast.success('Emby 账号已添加')
|
||||
}
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
|
||||
<div className="w-full max-w-md rounded-3xl border border-sand-200 bg-white p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-display text-xl font-bold text-ink-600">{existing ? '编辑 Emby 账号' : '添加 Emby 账号'}</h3>
|
||||
<p className="text-xs text-sand-500">连接远程 Emby 服务器(Jellyfin 暂不支持)</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-1.5 text-sand-500 hover:bg-gray-100">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={submit} className="space-y-4">
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block font-semibold text-ink-600">账号名称</span>
|
||||
<input className={inputCls} value={name} placeholder="例如:家庭影院" onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block font-semibold text-ink-600">Emby 服务地址</span>
|
||||
<input
|
||||
className={inputCls}
|
||||
value={url}
|
||||
placeholder="http://192.168.1.10:8096"
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
disabled={!!existing}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block font-semibold text-ink-600">用户名</span>
|
||||
<input className={inputCls} value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block font-semibold text-ink-600">密码</span>
|
||||
<input className={inputCls} type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block text-sm">
|
||||
<span className="mb-1 block font-semibold text-ink-600">API Key(可选)</span>
|
||||
<input
|
||||
className={inputCls}
|
||||
value={token}
|
||||
placeholder="留空则用用户名/密码自动认证"
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{existing && (
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
启用该账号
|
||||
</label>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button type="button" onClick={onClose} className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-semibold text-ink-100 hover:bg-gray-50">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="neon-button disabled:opacity-50">
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Server size={16} />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 添加挂载对话框 ────────────────────────────────────────────────────────────
|
||||
|
||||
function MountDialog({
|
||||
account,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
account: StrmAccount
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [views, setViews] = useState<RemoteEmbyView[]>([])
|
||||
const [selected, setSelected] = useState<Record<string, boolean>>({})
|
||||
const [proxy, setProxy] = useState<Record<string, boolean>>({})
|
||||
const nameOverride: Record<string, string> = {}
|
||||
const [allProxy, setAllProxy] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
embyAPI
|
||||
.listAccountViews(account.id)
|
||||
.then((rows) => {
|
||||
setViews(rows)
|
||||
const init: Record<string, boolean> = {}
|
||||
rows.forEach((v) => {
|
||||
if (!v.already_mounted) init[v.remote_view_id] = true
|
||||
})
|
||||
setSelected(init)
|
||||
})
|
||||
.catch((err) => toast.error(apiErrorMessage(err)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [account.id])
|
||||
|
||||
const toggleView = (id: string) => setSelected((prev) => ({ ...prev, [id]: !prev[id] }))
|
||||
const toggleProxy = (id: string) => setProxy((prev) => ({ ...prev, [id]: !prev[id] }))
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
const picked = views.filter((v) => selected[v.remote_view_id])
|
||||
if (picked.length === 0) {
|
||||
toast.error('请至少选择一个媒体库')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await embyAPI.createMounts({
|
||||
account_id: account.id,
|
||||
views: picked.map((v) => ({
|
||||
remote_view_id: v.remote_view_id,
|
||||
remote_view_name: v.remote_view_name,
|
||||
collection_type: v.collection_type,
|
||||
name: nameOverride[v.remote_view_id] || '',
|
||||
proxy_play: allProxy || proxy[v.remote_view_id] || false,
|
||||
})),
|
||||
})
|
||||
toast.success(`已挂载 ${picked.length} 个媒体库`)
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
|
||||
<div className="flex max-h-[85vh] w-full max-w-2xl flex-col rounded-3xl border border-sand-200 bg-white p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-display text-xl font-bold text-ink-600">选择要挂载的媒体库</h3>
|
||||
<p className="text-xs text-sand-500">
|
||||
「{account.name}」上的远程媒体库,勾选后出现在本项目媒体库;未勾选的不挂载
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-1.5 text-sand-500 hover:bg-gray-100">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-3 flex items-center justify-between rounded-xl bg-gray-50 px-4 py-2.5 text-sm">
|
||||
<label className="flex items-center gap-2 font-semibold text-ink-600">
|
||||
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={allProxy} onChange={(e) => setAllProxy(e.target.checked)} />
|
||||
全部走本服务器代理播放
|
||||
</label>
|
||||
<span className="text-xs text-sand-500">关闭(默认)= 客户端直连远程 Emby 拉流</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-10 text-sm text-sand-500">
|
||||
<Loader2 size={16} className="animate-spin" /> 正在读取远程媒体库…
|
||||
</div>
|
||||
) : views.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-sand-500">远程服务器没有可挂载的媒体库</p>
|
||||
) : (
|
||||
views.map((v) => {
|
||||
const checked = selected[v.remote_view_id]
|
||||
const disabled = v.already_mounted
|
||||
return (
|
||||
<div key={v.remote_view_id} className={`flex items-center gap-3 rounded-xl border px-3.5 py-2.5 ${disabled ? 'border-gray-100 bg-gray-50 opacity-60' : 'border-sand-200 bg-white'}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary-400"
|
||||
checked={disabled ? false : checked}
|
||||
disabled={disabled}
|
||||
onChange={() => toggleView(v.remote_view_id)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-ink-600">{v.remote_view_name}</p>
|
||||
<p className="text-xs text-sand-500">
|
||||
{v.collection_type || 'mixed'}
|
||||
{v.child_count > 0 ? ` · ${v.child_count} 条目` : ''}
|
||||
{disabled ? ' · 已挂载' : ''}
|
||||
</p>
|
||||
</div>
|
||||
{!disabled && (
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-1.5 text-xs font-semibold text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3.5 w-3.5 accent-primary-400"
|
||||
checked={proxy[v.remote_view_id] || false}
|
||||
disabled={allProxy}
|
||||
onChange={() => toggleProxy(v.remote_view_id)}
|
||||
/>
|
||||
代理
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button onClick={onClose} className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-semibold text-ink-100 hover:bg-gray-50">
|
||||
取消
|
||||
</button>
|
||||
<button type="button" onClick={submit} disabled={saving || loading} className="neon-button disabled:opacity-50">
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Tv size={16} />}
|
||||
挂载所选媒体库
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 页面主体 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function EmbyMountPage() {
|
||||
const [accounts, setAccounts] = useState<StrmAccount[]>([])
|
||||
const [mounts, setMounts] = useState<EmbyMount[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [accountDialog, setAccountDialog] = useState<StrmAccount | null | 'new'>(null)
|
||||
const [mountDialogFor, setMountDialogFor] = useState<StrmAccount | null>(null)
|
||||
const [testingID, setTestingID] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [accts, mts] = await Promise.all([
|
||||
strmAPI.listAccounts().then((rows) => rows.filter((a) => a.provider === 'emby_remote')),
|
||||
embyAPI.listMounts(),
|
||||
])
|
||||
setAccounts(accts)
|
||||
setMounts(mts)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
.catch((err) => toast.error(apiErrorMessage(err)))
|
||||
.finally(() => setLoading(false))
|
||||
}, [load])
|
||||
|
||||
const testAccount = async (acct: StrmAccount) => {
|
||||
setTestingID(acct.id)
|
||||
try {
|
||||
const updated = await strmAPI.testAccount(acct.id)
|
||||
toast.success(updated.last_test_ok ? `「${updated.name}」连接正常` : `连接失败:${updated.last_test_result}`)
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setTestingID(null)
|
||||
}
|
||||
}
|
||||
|
||||
const removeAccount = async (acct: StrmAccount) => {
|
||||
if (!(await confirmAction({ message: `删除 Emby 账号「${acct.name}」?其下 ${mounts.filter((m) => m.account_id === acct.id).length} 个挂载会一并移除。`, confirmText: '删除' }))) return
|
||||
try {
|
||||
await strmAPI.deleteAccount(acct.id)
|
||||
toast.success('账号已删除')
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
const fullMount = async (acct: StrmAccount) => {
|
||||
if (!(await confirmAction({ message: `把「${acct.name}」上的全部媒体库一键挂载(播放默认直连远程)?`, confirmText: '全量挂载' }))) return
|
||||
try {
|
||||
const res = await embyAPI.fullMountAccount(acct.id, false)
|
||||
toast.success(res.created > 0 ? `已挂载全部 ${res.created} 个媒体库` : '没有新的媒体库需要挂载')
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
const removeMount = async (mount: EmbyMount) => {
|
||||
if (!(await confirmAction({ message: `取消挂载「${mount.remote_view_name || mount.name}」?媒体库列表中将不再显示。`, confirmText: '取消挂载' }))) return
|
||||
try {
|
||||
await embyAPI.deleteMount(mount.id)
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMount = async (mount: EmbyMount, enabled: boolean) => {
|
||||
try {
|
||||
await embyAPI.updateMount(mount.id, { enabled })
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMountProxy = async (mount: EmbyMount, proxy: boolean) => {
|
||||
try {
|
||||
await embyAPI.updateMount(mount.id, { proxy_play: proxy })
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-display text-3xl font-bold text-ink-600">
|
||||
Emby 挂载
|
||||
<span className="text-sand-500">
|
||||
{' '}
|
||||
({mounts.length} 个媒体库)
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-sm text-ink-50">
|
||||
添加远程 Emby 服务器后,按需挂载其媒体库到本项目;播放默认直连原 Emby,可对单个媒体库开启本机代理
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setAccountDialog('new')} className="neon-button">
|
||||
<Server size={16} />
|
||||
添加 Emby 账号
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-20 text-sm text-sand-500">
|
||||
<Loader2 size={18} className="animate-spin" /> 加载中…
|
||||
</div>
|
||||
) : accounts.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-sand-300 bg-white/60 p-14 text-center">
|
||||
<Globe size={36} className="mx-auto mb-3 text-sand-400" />
|
||||
<p className="font-semibold text-ink-600">还没有 Emby 服务器</p>
|
||||
<p className="mt-1 text-sm text-sand-500">点击右上角「添加 Emby 账号」,填入服务器地址与凭据后即可挂载媒体库</p>
|
||||
</div>
|
||||
) : (
|
||||
accounts.map((acct) => {
|
||||
const acctMounts = mounts.filter((m) => m.account_id === acct.id)
|
||||
return (
|
||||
<div key={acct.id} className="overflow-hidden rounded-3xl border border-sand-200 bg-white shadow-card">
|
||||
{/* 账号头部 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-sand-100 bg-gradient-to-r from-brand-50/60 to-transparent px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-brand-100 text-brand-600">
|
||||
<Server size={20} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-display text-lg font-bold text-ink-600">{acct.name}</p>
|
||||
<p className="text-xs text-sand-500">
|
||||
{acct.has_credential ? '凭据已配置' : '待补全凭据'}
|
||||
{acct.last_test_result ? ` · 最近测试:${acct.last_test_ok ? '正常' : acct.last_test_result}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => testAccount(acct)} disabled={testingID === acct.id} className="iconButtonCls">
|
||||
{testingID === acct.id ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
|
||||
测试
|
||||
</button>
|
||||
<button onClick={() => setAccountDialog(acct)} className="iconButtonCls">
|
||||
<Pencil size={13} />
|
||||
编辑
|
||||
</button>
|
||||
<button onClick={() => removeAccount(acct)} className="iconButtonCls !text-red-500 hover:!bg-red-50">
|
||||
<Trash2 size={13} />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 挂载列表 */}
|
||||
<div className="px-5 py-4">
|
||||
{acctMounts.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-sand-500">
|
||||
尚未挂载媒体库 —— 点击下方「选择挂载」,或一键全量挂载该服务器的全部媒体库
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{acctMounts.map((m) => (
|
||||
<div key={m.id} className={`flex items-center gap-3 rounded-xl border px-4 py-3 ${m.enabled ? 'border-sand-200 bg-gray-50/60' : 'border-gray-100 bg-gray-50 opacity-60'}`}>
|
||||
<Tv size={16} className="shrink-0 text-brand-500" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-ink-600">
|
||||
{m.name || m.remote_view_name || '未命名'}
|
||||
</p>
|
||||
<p className="text-xs text-sand-500">
|
||||
{m.collection_type || 'mixed'}
|
||||
{m.proxy_play ? ' · 本机代理播放' : ' · 直连播放'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleMountProxy(m, !m.proxy_play)}
|
||||
disabled={!m.enabled}
|
||||
className="rounded-lg border border-gray-200 px-2.5 py-1.5 text-xs font-semibold text-ink-100 hover:bg-gray-100 disabled:opacity-40"
|
||||
title="切换播放代理"
|
||||
>
|
||||
{m.proxy_play ? '代理中' : '直连'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleMount(m, !m.enabled)}
|
||||
className="rounded-lg border border-gray-200 p-1.5 text-ink-100 hover:bg-gray-100"
|
||||
title={m.enabled ? '停用挂载' : '启用挂载'}
|
||||
>
|
||||
{m.enabled ? <Power size={14} /> : <PowerOff size={14} />}
|
||||
</button>
|
||||
<button onClick={() => removeMount(m)} className="rounded-lg border border-gray-200 p-1.5 text-red-500 hover:bg-red-50" title="取消挂载">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button onClick={() => setMountDialogFor(acct)} className="btn-outline">
|
||||
<Plus size={14} />
|
||||
选择挂载媒体库
|
||||
</button>
|
||||
<button onClick={() => fullMount(acct)} className="btn-outline">
|
||||
<Check size={14} />
|
||||
全量挂载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{accountDialog && (
|
||||
<AccountDialog existing={accountDialog === 'new' ? null : accountDialog} onClose={() => setAccountDialog(null)} onSaved={() => { setAccountDialog(null); load() }} />
|
||||
)}
|
||||
{mountDialogFor && <MountDialog account={mountDialogFor} onClose={() => setMountDialogFor(null)} onSaved={() => { setMountDialogFor(null); load() }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const iconButtonCls =
|
||||
'inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2 py-1 text-xs font-semibold text-ink-100 transition hover:bg-gray-50'
|
||||
|
||||
void iconButtonCls
|
||||
@@ -91,7 +91,6 @@ const PROVIDER_OPTIONS: { provider: StrmProvider; label: string; desc: string }[
|
||||
{ provider: 'cloud115', label: '115 网盘', desc: '开放平台 OAuth 授权(扫码/网页)' },
|
||||
{ provider: 'clouddrive2', label: 'CloudDrive2', desc: 'WebDAV 桥接多种网盘' },
|
||||
{ provider: 'openlist', label: 'OpenList', desc: 'OpenList / AList 兼容服务' },
|
||||
{ provider: 'emby_remote', label: 'Emby 远程挂载', desc: '聚合远程 Emby 媒体库,不落库实时透传' },
|
||||
]
|
||||
|
||||
export function StrmAccountDialog({
|
||||
|
||||
@@ -74,7 +74,7 @@ export function StrmManagePage() {
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [accts, pths, recs] = await Promise.all([
|
||||
strmAPI.listAccounts(),
|
||||
strmAPI.listAccounts().then((rows) => rows.filter((a) => a.provider !== 'emby_remote')),
|
||||
strmAPI.listPaths(),
|
||||
strmAPI.listRecords(),
|
||||
])
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Emby 远程挂载类型定义(与后端 internal/model/emby_mount.go 对应)。
|
||||
export interface EmbyMount {
|
||||
id: string
|
||||
account_id: string
|
||||
remote_view_id: string
|
||||
remote_view_name: string
|
||||
collection_type: string
|
||||
name: string
|
||||
proxy_play: boolean
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
/** 所属账号名(接口侧拼装) */
|
||||
account_name?: string
|
||||
}
|
||||
|
||||
export interface EmbyMountInput {
|
||||
account_id: string
|
||||
views: EmbyViewInput[]
|
||||
}
|
||||
|
||||
export interface EmbyViewInput {
|
||||
remote_view_id: string
|
||||
remote_view_name: string
|
||||
collection_type: string
|
||||
name?: string
|
||||
proxy_play: boolean
|
||||
}
|
||||
|
||||
export interface RemoteEmbyView {
|
||||
remote_view_id: string
|
||||
remote_view_name: string
|
||||
collection_type: string
|
||||
child_count: number
|
||||
already_mounted: boolean
|
||||
}
|
||||
Reference in New Issue
Block a user