diff --git a/internal/handler/emby_mounts.go b/internal/handler/emby_mounts.go new file mode 100644 index 0000000..c1b6a85 --- /dev/null +++ b/internal/handler/emby_mounts.go @@ -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}) + } +} \ No newline at end of file diff --git a/internal/handler/media.go b/internal/handler/media.go index 1ff61e2..9a46e51 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -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()}) diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index 0f5ce22..de2a76b 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -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)) diff --git a/internal/handler/series.go b/internal/handler/series.go index c1e6f61..f0e39b7 100644 --- a/internal/handler/series.go +++ b/internal/handler/series.go @@ -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 diff --git a/internal/model/emby_mount.go b/internal/model/emby_mount.go new file mode 100644 index 0000000..5b81806 --- /dev/null +++ b/internal/model/emby_mount.go @@ -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"` // 是否在媒体库中展示 +} \ No newline at end of file diff --git a/internal/model/model.go b/internal/model/model.go index 6d7d355..36c02aa 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -58,5 +58,6 @@ func AllModels() []interface{} { &StrmUploadTask{}, &StrmDirCache{}, &ScrapeTask{}, + &EmbyMount{}, } } diff --git a/internal/repository/emby_mount_repository.go b/internal/repository/emby_mount_repository.go new file mode 100644 index 0000000..9de20b0 --- /dev/null +++ b/internal/repository/emby_mount_repository.go @@ -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 +} \ No newline at end of file diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 2ebe721..cc8d738 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -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}, } } diff --git a/internal/service/cloud/emby.go b/internal/service/cloud/emby.go index e9b00a4..b2677ee 100644 --- a/internal/service/cloud/emby.go +++ b/internal/service/cloud/emby.go @@ -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, "/") diff --git a/internal/service/emby_artwork.go b/internal/service/emby_artwork.go index 97ed176..57e1318 100644 --- a/internal/service/emby_artwork.go +++ b/internal/service/emby_artwork.go @@ -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) diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index a99f13a..a775e12 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -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", diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go index 4f1cf04..8182b03 100644 --- a/internal/service/emby_items_detail.go +++ b/internal/service/emby_items_detail.go @@ -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 diff --git a/internal/service/emby_playback.go b/internal/service/emby_playback.go index c2c0155..b97d927 100644 --- a/internal/service/emby_playback.go +++ b/internal/service/emby_playback.go @@ -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) diff --git a/internal/service/emby_remote.go b/internal/service/emby_remote.go index 598616a..31424fa 100644 --- a/internal/service/emby_remote.go +++ b/internal/service/emby_remote.go @@ -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 diff --git a/internal/service/emby_remote_web.go b/internal/service/emby_remote_web.go index 2101e3c..301f96d 100644 --- a/internal/service/emby_remote_web.go +++ b/internal/service/emby_remote_web.go @@ -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" -} \ No newline at end of file +} +// ─── 供 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") } diff --git a/internal/service/emby_system.go b/internal/service/emby_system.go index 8ee1cc2..a3f92b4 100644 --- a/internal/service/emby_system.go +++ b/internal/service/emby_system.go @@ -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": diff --git a/internal/service/service.go b/internal/service/service.go index 1853cb7..885d447 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -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) diff --git a/web/src/api/emby.ts b/web/src/api/emby.ts new file mode 100644 index 0000000..a764944 --- /dev/null +++ b/web/src/api/emby.ts @@ -0,0 +1,19 @@ +import { api } from './client' +import type { EmbyMount, EmbyMountInput, RemoteEmbyView } from '../types/emby' + +export const embyAPI = { + listMounts: () => api.get('/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(`/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(`/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), +} \ No newline at end of file diff --git a/web/src/appRoutes.tsx b/web/src/appRoutes.tsx index ff1fe8d..825c83e 100644 --- a/web/src/appRoutes.tsx +++ b/web/src/appRoutes.tsx @@ -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: }, { path: 'files', element: , adminOnly: true }, { path: 'settings', element: , adminOnly: true }, + { path: 'emby-mount', element: , adminOnly: true }, { path: 'strm', element: , adminOnly: true }, { path: 'strm/downloads', element: , adminOnly: true }, { path: 'strm/uploads', element: , adminOnly: true }, diff --git a/web/src/components/layoutNavigation.ts b/web/src/components/layoutNavigation.ts index bc3c711..83dafa1 100644 --- a/web/src/components/layoutNavigation.ts +++ b/web/src/components/layoutNavigation.ts @@ -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 }, diff --git a/web/src/pages/EmbyMountPage.tsx b/web/src/pages/EmbyMountPage.tsx new file mode 100644 index 0000000..e3b6ccb --- /dev/null +++ b/web/src/pages/EmbyMountPage.tsx @@ -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 = { + ...(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 ( +
+
e.stopPropagation()}> +
+
+

{existing ? '编辑 Emby 账号' : '添加 Emby 账号'}

+

连接远程 Emby 服务器(Jellyfin 暂不支持)

+
+ +
+
+ + +
+ + +
+ + {existing && ( + + )} +
+ + +
+
+
+
+ ) +} + +// ─── 添加挂载对话框 ──────────────────────────────────────────────────────────── + +function MountDialog({ + account, + onClose, + onSaved, +}: { + account: StrmAccount + onClose: () => void + onSaved: () => void +}) { + const [views, setViews] = useState([]) + const [selected, setSelected] = useState>({}) + const [proxy, setProxy] = useState>({}) + const nameOverride: Record = {} + 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 = {} + 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 ( +
+
e.stopPropagation()}> +
+
+

选择要挂载的媒体库

+

+ 「{account.name}」上的远程媒体库,勾选后出现在本项目媒体库;未勾选的不挂载 +

+
+ +
+
+ + 关闭(默认)= 客户端直连远程 Emby 拉流 +
+
+ {loading ? ( +
+ 正在读取远程媒体库… +
+ ) : views.length === 0 ? ( +

远程服务器没有可挂载的媒体库

+ ) : ( + views.map((v) => { + const checked = selected[v.remote_view_id] + const disabled = v.already_mounted + return ( +
+ toggleView(v.remote_view_id)} + /> +
+

{v.remote_view_name}

+

+ {v.collection_type || 'mixed'} + {v.child_count > 0 ? ` · ${v.child_count} 条目` : ''} + {disabled ? ' · 已挂载' : ''} +

+
+ {!disabled && ( + + )} +
+ ) + }) + )} +
+
+ + +
+
+
+ ) +} + +// ─── 页面主体 ────────────────────────────────────────────────────────────────── + +export function EmbyMountPage() { + const [accounts, setAccounts] = useState([]) + const [mounts, setMounts] = useState([]) + const [loading, setLoading] = useState(true) + const [accountDialog, setAccountDialog] = useState(null) + const [mountDialogFor, setMountDialogFor] = useState(null) + const [testingID, setTestingID] = useState(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 ( +
+
+
+

+ Emby 挂载 + + {' '} + ({mounts.length} 个媒体库) + +

+

+ 添加远程 Emby 服务器后,按需挂载其媒体库到本项目;播放默认直连原 Emby,可对单个媒体库开启本机代理 +

+
+ +
+ + {loading ? ( +
+ 加载中… +
+ ) : accounts.length === 0 ? ( +
+ +

还没有 Emby 服务器

+

点击右上角「添加 Emby 账号」,填入服务器地址与凭据后即可挂载媒体库

+
+ ) : ( + accounts.map((acct) => { + const acctMounts = mounts.filter((m) => m.account_id === acct.id) + return ( +
+ {/* 账号头部 */} +
+
+ + + +
+

{acct.name}

+

+ {acct.has_credential ? '凭据已配置' : '待补全凭据'} + {acct.last_test_result ? ` · 最近测试:${acct.last_test_ok ? '正常' : acct.last_test_result}` : ''} +

+
+
+
+ + + +
+
+ + {/* 挂载列表 */} +
+ {acctMounts.length === 0 ? ( +

+ 尚未挂载媒体库 —— 点击下方「选择挂载」,或一键全量挂载该服务器的全部媒体库 +

+ ) : ( +
+ {acctMounts.map((m) => ( +
+ +
+

+ {m.name || m.remote_view_name || '未命名'} +

+

+ {m.collection_type || 'mixed'} + {m.proxy_play ? ' · 本机代理播放' : ' · 直连播放'} +

+
+ + + +
+ ))} +
+ )} +
+ + +
+
+
+ ) + }) + )} + + {accountDialog && ( + setAccountDialog(null)} onSaved={() => { setAccountDialog(null); load() }} /> + )} + {mountDialogFor && setMountDialogFor(null)} onSaved={() => { setMountDialogFor(null); load() }} />} +
+ ) +} + +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 \ No newline at end of file diff --git a/web/src/pages/StrmDialogs.tsx b/web/src/pages/StrmDialogs.tsx index a2f8bd8..9987873 100644 --- a/web/src/pages/StrmDialogs.tsx +++ b/web/src/pages/StrmDialogs.tsx @@ -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({ diff --git a/web/src/pages/StrmManagePage.tsx b/web/src/pages/StrmManagePage.tsx index 515f43b..6bef158 100644 --- a/web/src/pages/StrmManagePage.tsx +++ b/web/src/pages/StrmManagePage.tsx @@ -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(), ]) diff --git a/web/src/types/emby.ts b/web/src/types/emby.ts new file mode 100644 index 0000000..092cb07 --- /dev/null +++ b/web/src/types/emby.ts @@ -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 +} \ No newline at end of file