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

- 新增 EmbyMount 表与 /admin/emby/mounts CRUD:把远程 Emby 的媒体库按需
  挂载到本项目(同一服务器可选择性挂载/全量挂载),代理开关下沉到每个
  挂载(一个 Emby 服务器可部分库代理、部分库直连)
- 伪装 ID 改为 embyremote~{mountID}~{remoteID}:播放/详情/状态/图片链路
  全部经 ResolveMount 解析挂载与账号
- 新增「Emby 挂载」独立菜单页(/emby-mount):账号管理(添加/测试/编辑/
  删除)+ 挂载列表(代理切换/停用/取消)+ 选择挂载对话框(多选+全量)
- STRM 管理页过滤 emby_remote 账号;账号表单移除 emby 类型
- Boot 幂等 AutoSeedMounts:旧 emby 账号自动全量挂载(沿用旧代理配置),
  升级后媒体库不消失
- 修复:远程流请求注入浏览器 UA(防 Cloudflare 风控)+ 代理强制 Static=true
  (阻止远程 ffmpeg 转码调度,反代纯字节中继)
- 全局搜索改为按挂载逐个搜索(结果归属与伪装 ID 正确)
This commit is contained in:
truewhile
2026-09-01 13:53:15 +08:00
parent 5a5555d28a
commit 73b95a8e38
24 changed files with 1331 additions and 186 deletions
+198
View File
@@ -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
View File
@@ -100,11 +100,12 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
wl := webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
if withPreview {
if acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID); acct != nil {
tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}}
itemTypes := remoteLibraryItemTypes(v.CollectionType)
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
wl.Total = total
}
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, acct, v.RemoteID, limit); err == nil {
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
wl.Cards = cards
}
}
@@ -123,8 +124,8 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc {
id := c.Param("id")
// 远程 Emby 挂载库详情。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
acctID, remoteID, _ := service.DecodeEmbyRemoteID(id)
view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, acctID, remoteID)
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, mountID, remoteID)
if err != nil || view == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
@@ -302,17 +303,17 @@ func listMediaHandler(svc *service.Container) gin.HandlerFunc {
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
// 远程 Emby 库:转发远程直属条目并映射为本地 Media 结构(分页由远程承接)。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
acctID, remoteID, _ := service.DecodeEmbyRemoteID(id)
acct := svc.EmbyRemote.AccountByID(ctx, acctID)
if acct == nil {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
itemTypes := ""
if view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, acctID, remoteID); err == nil && view != nil {
if view, err := svc.EmbyRemote.RemoteLibraryByID(ctx, mountID, remoteID); err == nil && view != nil {
itemTypes = remoteLibraryItemTypes(view.CollectionType)
}
items, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, acct, remoteID, itemTypes, (page-1)*size, size)
items, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, mount, acct, remoteID, itemTypes, (page-1)*size, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -369,13 +370,13 @@ func getMediaHandler(svc *service.Container) gin.HandlerFunc {
id := c.Param("id")
// 远程 Emby 条目:拉远程详情并映射为本地 Media 结构。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
acctID, remoteID, _ := service.DecodeEmbyRemoteID(id)
acct := svc.EmbyRemote.AccountByID(ctx, acctID)
if acct == nil {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
m, err := svc.EmbyRemote.RemoteMediaDetail(ctx, acct, remoteID)
m, err := svc.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -487,14 +488,22 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
// 远程 Emby 条目:302 直连远程流(网页播放器 video 标签跟随重定向)。
// 远程 Emby 条目:按挂载代理配置分流——代理走 MMTL 反代,否则 302 直连。
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
acctID, remoteID, _ := service.DecodeEmbyRemoteID(id)
acct := svc.EmbyRemote.AccountByID(ctx, acctID)
if acct == nil {
mountID, remoteID, _ := service.DecodeEmbyRemoteID(id)
mount, acct, _ := svc.EmbyRemote.ResolveMount(ctx, mountID)
if mount == nil || acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if mount.ProxyPlay {
if err := svc.Emby.ProxyRemoteVideoStream(ctx, c.Writer, c.Request, mountID, remoteID); err != nil {
if !c.Writer.Written() {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
}
return
}
target, err := svc.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
+8
View File
@@ -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
View File
@@ -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
+19
View File
@@ -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"` // 是否在媒体库中展示
}
+1
View File
@@ -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
}
+2
View File
@@ -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},
}
}
+13 -1
View File
@@ -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, "/")
+3 -3
View File
@@ -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)
+20 -9
View File
@@ -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",
+8 -8
View File
@@ -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
+17 -17
View File
@@ -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
View File
@@ -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
+91 -43
View File
@@ -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") }
+14 -53
View File
@@ -5,8 +5,6 @@ import (
"strings"
"time"
"go.uber.org/zap"
"github.com/ShukeBta/MMTL/internal/model"
)
@@ -148,62 +146,38 @@ func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any,
return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil
}
// remoteViews 拉取全部启用远程 Emby 账号的媒体库视图。单个账号失败只跳过
// 该账号(日志记录),不影响其他来源。
// remoteViews 返回全部启用挂载的远程媒体库视图(只有显式挂载的库才出现)。
func (e *EmbyService) remoteViews(ctx context.Context) []map[string]any {
if e == nil || e.remote == nil {
return nil
}
accounts, err := e.remote.ListAccounts(ctx)
if err != nil || len(accounts) == 0 {
views, err := e.remote.RemoteLibraries(ctx)
if err != nil || len(views) == 0 {
return nil
}
out := make([]map[string]any, 0, len(accounts)*2)
for i := range accounts {
acct := &accounts[i]
views, err := e.remote.RemoteViews(ctx, acct)
if err != nil {
if e.log != nil {
e.log.Warn("fetch remote emby views failed",
zap.String("account", acct.Name), zap.Error(err))
}
continue
}
for _, v := range views {
if view := e.remoteLibraryAsView(v, acct); view != nil {
out = append(out, view)
}
}
out := make([]map[string]any, 0, len(views))
for _, v := range views {
out = append(out, remoteLibraryViewPayload(v))
}
return out
}
// remoteLibraryAsView 把远程媒体的 CollectionFolder 视图标准化为本地视图
// 同构的 payload(ID 伪装、名称前缀账号名以便区分多个远程来源)。
func (e *EmbyService) remoteLibraryAsView(raw map[string]any, acct *model.StrmAccount) map[string]any {
if raw == nil {
return nil
}
remoteID, _ := raw["Id"].(string)
if remoteID == "" {
return nil
}
name, _ := raw["Name"].(string)
if name == "" {
name = acct.Name
}
collectionType, _ := raw["CollectionType"].(string)
// remoteLibraryViewPayload 把挂载库展示信息标准化为 Emby View payload
// (ID 用挂载伪装,名称=挂载显示名)。
func remoteLibraryViewPayload(v RemoteLibraryView) map[string]any {
encoded := EncodeEmbyRemoteID(v.MountID, v.RemoteID)
collectionType := v.CollectionType
if !isSupportedEmbyCollectionType(collectionType) {
collectionType = "mixed"
}
encoded := EncodeEmbyRemoteID(acct.ID, remoteID)
name := strings.TrimSpace(v.Library.Name)
imageTags := map[string]string{}
if primary := embyRemoteImageTagsPrimary(raw); primary != "" {
if strings.TrimSpace(v.Library.CoverURL) != "" {
imageTags["Primary"] = encoded
}
return map[string]any{
"Id": encoded,
"Name": acct.Name + " · " + name,
"Name": name,
"CollectionType": collectionType,
"ServerId": embyServerID,
"Type": "CollectionFolder",
@@ -237,19 +211,6 @@ func (e *EmbyService) remoteLibraryAsView(raw map[string]any, acct *model.StrmAc
}
}
// embyRemoteImageTagsPrimary 提取远程 View 的 ImageTags.Primary 值。
func embyRemoteImageTagsPrimary(raw map[string]any) string {
switch tags := raw["ImageTags"].(type) {
case map[string]any:
if s, ok := tags["Primary"].(string); ok {
return s
}
case map[string]string:
return tags["Primary"]
}
return ""
}
func isSupportedEmbyCollectionType(t string) bool {
switch t {
case "movies", "tvshows", "music", "mixed", "homevideos", "boxsets":
+5
View File
@@ -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)
+19
View File
@@ -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),
}
+2
View File
@@ -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 },
+3
View File
@@ -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 },
+518
View File
@@ -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
-1
View File
@@ -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({
+1 -1
View File
@@ -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(),
])
+36
View File
@@ -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
}