mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
bug处理
This commit is contained in:
@@ -197,6 +197,32 @@ func deleteEmbyMountHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// setEmbyMountsEnabledHandler 一键启用/停用某账号下的全部挂载。
|
||||
//
|
||||
// 远端整站不可用时(如 Cloudflare 522),管理员可以用它把该账号的挂载
|
||||
// 全部从媒体库摘掉,首页立刻只返回本地库;远端恢复后再一键启用。
|
||||
func setEmbyMountsEnabledHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if svc.EmbyRemote == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "emby remote service not available"})
|
||||
return
|
||||
}
|
||||
affected, err := svc.EmbyRemote.SetMountsEnabled(c.Request.Context(), c.Param("id"), *req.Enabled)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "affected": affected})
|
||||
}
|
||||
}
|
||||
|
||||
type reorderEmbyMountsReq struct {
|
||||
IDs []string `json:"ids" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/database"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// TestSetEmbyMountsEnabledHandler 覆盖「一键停用/启用某账号下全部挂载」:
|
||||
// 只影响目标账号,且配置保留(不是删除)。
|
||||
func TestSetEmbyMountsEnabledHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.AutoMigrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
ctx := t.Context()
|
||||
|
||||
m1 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-1", Enabled: true}
|
||||
m2 := &model.EmbyMount{AccountID: "acct-1", RemoteViewID: "view-2", Enabled: true}
|
||||
other := &model.EmbyMount{AccountID: "acct-2", RemoteViewID: "view-3", Enabled: true}
|
||||
for _, m := range []*model.EmbyMount{m1, m2, other} {
|
||||
if err := repos.EmbyMount.Create(ctx, m); err != nil {
|
||||
t.Fatalf("create mount: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
EmbyRemote: service.NewEmbyRemoteService(nil, zap.NewNop(), repos, nil),
|
||||
}
|
||||
router := gin.New()
|
||||
router.PUT("/admin/emby/accounts/:id/mounts/enabled", setEmbyMountsEnabledHandler(svc))
|
||||
|
||||
call := func(body map[string]any) *httptest.ResponseRecorder {
|
||||
raw, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest(http.MethodPut, "/admin/emby/accounts/acct-1/mounts/enabled", bytes.NewReader(raw))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
w := call(map[string]any{"enabled": false})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
OK bool `json:"ok"`
|
||||
Affected int64 `json:"affected"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if !resp.OK || resp.Affected != 2 {
|
||||
t.Fatalf("expected ok with affected=2, got %+v", resp)
|
||||
}
|
||||
|
||||
rows, err := repos.EmbyMount.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
want := row.AccountID != "acct-1" // 目标账号停用,其他账号不受影响
|
||||
if row.Enabled != want {
|
||||
t.Fatalf("mount %s enabled=%v, want %v", row.ID, row.Enabled, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 重新启用:挂载记录仍在,只是开关回 true。
|
||||
w = call(map[string]any{"enabled": true})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
rows, err = repos.EmbyMount.List(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 3 {
|
||||
t.Fatalf("expected mounts to be preserved, got %d rows", len(rows))
|
||||
}
|
||||
for _, row := range rows {
|
||||
if !row.Enabled {
|
||||
t.Fatalf("mount %s should be enabled again", row.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 缺少 enabled 字段必须拒绝,避免误把全部挂载停掉。
|
||||
if w := call(map[string]any{}); w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for missing enabled, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ 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.PUT("/emby/accounts/:id/mounts/enabled", setEmbyMountsEnabledHandler(svc))
|
||||
admin.GET("/emby/mounts", listEmbyMountsHandler(svc))
|
||||
admin.POST("/emby/mounts", createEmbyMountsHandler(svc))
|
||||
admin.PUT("/emby/mounts/reorder", reorderEmbyMountsHandler(svc))
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -89,6 +90,22 @@ func (r *EmbyMountRepository) SetSortOrder(ctx context.Context, ids []string) er
|
||||
})
|
||||
}
|
||||
|
||||
// SetEnabledByAccountID 批量设置账号下全部挂载的启用状态。
|
||||
func (r *EmbyMountRepository) SetEnabledByAccountID(ctx context.Context, accountID string, enabled bool) (int64, error) {
|
||||
var affected int64
|
||||
if strings.TrimSpace(accountID) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
err := withSQLiteBusyRetry(ctx, func() error {
|
||||
res := r.db.WithContext(ctx).Model(&model.EmbyMount{}).
|
||||
Where("account_id = ?", accountID).
|
||||
Updates(map[string]any{"enabled": enabled, "updated_at": time.Now()})
|
||||
affected = res.RowsAffected
|
||||
return res.Error
|
||||
})
|
||||
return affected, 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
|
||||
|
||||
@@ -40,6 +40,23 @@ import (
|
||||
// embyRemoteHTTPTimeout 远程 Emby 常规 API 请求超时(流式代理不在此列)。
|
||||
const embyRemoteHTTPTimeout = 15 * time.Second
|
||||
|
||||
// embyRemoteViewsBudget 是网页端聚合远程媒体库时的总时间预算。
|
||||
//
|
||||
// 上游 Emby 整体不可用时(典型表现:Cloudflare 522,连接卡住近 20 秒),
|
||||
// 首页 /api/libraries 会被同步阻塞,而前端 axios 30 秒就放弃,于是一个挂掉的
|
||||
// 远端会把整个首页拖成加载失败。这里给聚合调用一个硬预算:超时即降级为
|
||||
// 「只返回本地库」,把首屏确定性地控制在几秒内。
|
||||
var embyRemoteViewsBudget = 5 * time.Second
|
||||
|
||||
// embyRemoteViewsFailureTTL 是远程 Views 拉取失败后的负缓存时长。
|
||||
//
|
||||
// 失败结果也缓存一小段时间,避免每个请求都重新等满预算(首页每次刷新、
|
||||
// 每个并发客户端都会各打一次)。时间很短,远端恢复后最多 30 秒即可自动重试。
|
||||
var embyRemoteViewsFailureTTL = 30 * time.Second
|
||||
|
||||
// errRemoteViewsRecentlyFailed 表示远程 Emby 刚失败过,当前处于负缓存窗口。
|
||||
var errRemoteViewsRecentlyFailed = errors.New("远程 Emby 暂时不可用(近期请求失败,稍后自动重试)")
|
||||
|
||||
// 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"
|
||||
@@ -422,6 +439,22 @@ func (r *EmbyRemoteService) DeleteMount(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// SetMountsEnabled 批量启用/停用某账号下的全部挂载。
|
||||
//
|
||||
// 账号整体不可用时(远端宕机、线路全挂),管理员可以一键把所有挂载从媒体库
|
||||
// 中摘掉:网页端 /api/libraries 不再为该账号发起任何远程请求,首页恢复秒开;
|
||||
// 远端恢复后再一键启用即可,挂载配置和播放设置都不丢。
|
||||
func (r *EmbyRemoteService) SetMountsEnabled(ctx context.Context, accountID string, enabled bool) (int64, error) {
|
||||
affected, err := r.repo.EmbyMount.SetEnabledByAccountID(ctx, accountID, enabled)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// 启停会改变可见库集合,顺手清掉远程元数据缓存(含 Views 失败负缓存),
|
||||
// 让下一次请求立刻按新状态重新探测,而不是继续吃 30 秒的负缓存。
|
||||
r.invalidateRemoteMediaCache(ctx)
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// ReorderMounts 批量重排挂载媒体库顺序。
|
||||
func (r *EmbyRemoteService) ReorderMounts(ctx context.Context, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
@@ -822,7 +855,13 @@ func (r *EmbyRemoteService) TestConnection(ctx context.Context, acct *model.Strm
|
||||
return err
|
||||
}
|
||||
var out json.RawMessage
|
||||
return r.doGet(ctx, acct, cfg, "/System/Info", nil, &out)
|
||||
if err := r.doGet(ctx, acct, cfg, "/System/Info", nil, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
// 连通性恢复:清掉 Views 失败负缓存与旧元数据,让下一次媒体库列表请求
|
||||
// 立即重新探测,而不是继续在负缓存窗口里显示「没有远程库」。
|
||||
r.invalidateRemoteMediaCache(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProxyPlayOf 返回账号是否配置了播放代理(供账号列表/编辑回显)。
|
||||
@@ -837,7 +876,34 @@ func (r *EmbyRemoteService) ProxyPlayOf(acct *model.StrmAccount) (bool, error) {
|
||||
// ─── 元数据 / 目录聚合 ─────────────────────────────────────────────────────────
|
||||
|
||||
// RemoteViews 拉取远程媒体库(View)列表,返回远程原始 view map(未重写)。
|
||||
//
|
||||
// 失败时写入短时负缓存:远程整站不可用时,避免每次首页刷新都重新等满超时。
|
||||
func (r *EmbyRemoteService) RemoteViews(ctx context.Context, acct *model.StrmAccount) ([]map[string]any, error) {
|
||||
if acct == nil {
|
||||
return nil, errors.New("缺少远程 Emby 账号")
|
||||
}
|
||||
failKey := r.remoteCacheKey("views-fail", acct.ID)
|
||||
var failed bool
|
||||
if r.cache != nil && r.cache.GetJSON(ctx, failKey, &failed) && failed {
|
||||
return nil, errRemoteViewsRecentlyFailed
|
||||
}
|
||||
views, err := r.remoteViewsUncached(ctx, acct)
|
||||
if err != nil {
|
||||
// 客户端主动断开(切换页面 / 关标签)不算上游故障,不能让它把
|
||||
// 负缓存窗口写进去,否则正常用户会被别人的一次取消拖慢 30 秒。
|
||||
if r.cache != nil && !errors.Is(err, context.Canceled) {
|
||||
r.cache.SetJSON(ctx, failKey, true, embyRemoteViewsFailureTTL)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if r.cache != nil {
|
||||
r.cache.SetJSON(ctx, failKey, false, embyRemoteViewsFailureTTL)
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
// remoteViewsUncached 是 RemoteViews 的实际拉取逻辑(含正常结果缓存)。
|
||||
func (r *EmbyRemoteService) remoteViewsUncached(ctx context.Context, acct *model.StrmAccount) ([]map[string]any, error) {
|
||||
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// TestRemoteLibrariesFastFailAndNegativeCache 覆盖首页可用的底线保证:
|
||||
// 上游 Emby 整体挂起时,聚合远程库必须在一个很短的预算内失败并降级为
|
||||
// 「没有远程库」,并且在负缓存窗口内不再重复打上游。
|
||||
func TestRemoteLibrariesFastFailAndNegativeCache(t *testing.T) {
|
||||
oldBudget, oldTTL := embyRemoteViewsBudget, embyRemoteViewsFailureTTL
|
||||
embyRemoteViewsBudget = 200 * time.Millisecond
|
||||
embyRemoteViewsFailureTTL = time.Minute
|
||||
t.Cleanup(func() {
|
||||
embyRemoteViewsBudget = oldBudget
|
||||
embyRemoteViewsFailureTTL = oldTTL
|
||||
})
|
||||
|
||||
release := make(chan struct{})
|
||||
var hits int32
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
// 模拟 Cloudflare 522 前近 20 秒的挂起:请求一直不返回。
|
||||
select {
|
||||
case <-release:
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
defer close(release)
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
remote := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop())).
|
||||
SetRuntimeCache(NewRuntimeCacheService(nil, zap.NewNop()))
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": upstream.URL,
|
||||
"api_key": "test-api-key",
|
||||
"remote_user_id": "uid-1",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-hang"},
|
||||
Name: "挂起的远端",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
mount := &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-hang"},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: "view-1",
|
||||
RemoteViewName: "卡住的库",
|
||||
CollectionType: "movies",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||
t.Fatalf("create mount: %v", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
views, err := remote.RemoteLibraries(t.Context())
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoteLibraries should degrade instead of failing: %v", err)
|
||||
}
|
||||
if len(views) != 0 {
|
||||
t.Fatalf("expected no remote libraries while upstream hangs, got %d", len(views))
|
||||
}
|
||||
if elapsed > 2*time.Second {
|
||||
t.Fatalf("RemoteLibraries took %v, want within the short budget", elapsed)
|
||||
}
|
||||
if atomic.LoadInt32(&hits) == 0 {
|
||||
t.Fatal("expected upstream to be probed once")
|
||||
}
|
||||
|
||||
// 负缓存窗口内:不再打上游,且立即返回空结果。
|
||||
before := atomic.LoadInt32(&hits)
|
||||
start = time.Now()
|
||||
views, err = remote.RemoteLibraries(t.Context())
|
||||
elapsed = time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("second RemoteLibraries should not fail: %v", err)
|
||||
}
|
||||
if len(views) != 0 {
|
||||
t.Fatalf("expected no remote libraries from negative cache, got %d", len(views))
|
||||
}
|
||||
if elapsed > time.Second {
|
||||
t.Fatalf("negative cache should answer immediately, took %v", elapsed)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != before {
|
||||
t.Fatalf("negative cache should skip upstream, hits went %d -> %d", before, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteViewsNotNegativeCachedOnClientCancel 保证客户端主动断开不会污染
|
||||
// 负缓存:否则一个用户切页就会让所有人在 30 秒内看不到远程库。
|
||||
func TestRemoteViewsNotNegativeCachedOnClientCancel(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-release:
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
defer close(release)
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
cache := NewRuntimeCacheService(nil, zap.NewNop())
|
||||
remote := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop())).
|
||||
SetRuntimeCache(cache)
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": upstream.URL,
|
||||
"api_key": "test-api-key",
|
||||
"remote_user_id": "uid-1",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-cancel"},
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
if _, err := remote.RemoteViews(ctx, acct); err == nil {
|
||||
t.Fatal("expected canceled context to fail")
|
||||
}
|
||||
|
||||
var failed bool
|
||||
if cache.GetJSON(t.Context(), remote.remoteCacheKey("views-fail", acct.ID), &failed) && failed {
|
||||
t.Fatal("client cancel must not write the negative cache")
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,12 @@ type RemoteLibraryView struct {
|
||||
// RemoteLibraries 把所有启用挂载的远程媒体库映射为网页媒体库列表
|
||||
// (只有显式挂载的库才出现在本项目媒体库中)。
|
||||
func (r *EmbyRemoteService) RemoteLibraries(ctx context.Context) ([]RemoteLibraryView, error) {
|
||||
// 远程库只是「追加」到本地库列表上,绝不允许它拖住整页首屏:给整段
|
||||
// 聚合(多账号 × 多线路故障转移)一个总预算,超时后各账号按失败处理,
|
||||
// 调用方随即降级为只返回本地库。
|
||||
ctx, cancel := context.WithTimeout(ctx, embyRemoteViewsBudget)
|
||||
defer cancel()
|
||||
|
||||
mounts, err := r.ListMounts(ctx)
|
||||
if err != nil || len(mounts) == 0 {
|
||||
return nil, err
|
||||
|
||||
@@ -10,6 +10,10 @@ export const embyAPI = {
|
||||
deleteMount: (id: string) => api.delete(`/admin/emby/mounts/${id}`).then((r) => r.data),
|
||||
reorderMounts: (ids: string[]) =>
|
||||
api.put<{ ok: boolean }>('/admin/emby/mounts/reorder', { ids }).then((r) => r.data),
|
||||
setAccountMountsEnabled: (accountId: string, enabled: boolean) =>
|
||||
api
|
||||
.put<{ ok: boolean; affected: number }>(`/admin/emby/accounts/${accountId}/mounts/enabled`, { enabled })
|
||||
.then((r) => r.data),
|
||||
listAccountViews: (accountId: string) =>
|
||||
api.get<RemoteEmbyView[]>(`/admin/emby/accounts/${accountId}/views`).then((r) => r.data),
|
||||
fullMountAccount: (accountId: string, proxy: boolean) =>
|
||||
|
||||
@@ -370,6 +370,8 @@ export function EmbyMountPage() {
|
||||
const [accountDialog, setAccountDialog] = useState<StrmAccount | null | 'new'>(null)
|
||||
const [mountDialogFor, setMountDialogFor] = useState<StrmAccount | null>(null)
|
||||
const [testingID, setTestingID] = useState<string | null>(null)
|
||||
const [togglingAccountID, setTogglingAccountID] = useState<string | null>(null)
|
||||
const [bulkTogglingID, setBulkTogglingID] = useState<string | null>(null)
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null)
|
||||
const [dragOverId, setDragOverId] = useState<string | null>(null)
|
||||
const [reordering, setReordering] = useState(false)
|
||||
@@ -504,6 +506,46 @@ export function EmbyMountPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 账号级总开关:停用后该服务器不再参与任何远程请求,其下挂载从媒体库中
|
||||
// 立即隐藏(配置保留),用于远端宕机时先让首页恢复可用。
|
||||
const toggleAccount = async (acct: StrmAccount) => {
|
||||
const next = !acct.enabled
|
||||
const acctMountCount = mounts.filter((m) => m.account_id === acct.id).length
|
||||
if (!next && !(await confirmAction({
|
||||
message: `停用 Emby 服务器「${acct.name}」?其下 ${acctMountCount} 个远程媒体库会立即从媒体库中隐藏,首页也不再请求该服务器;挂载配置与播放设置会保留,可随时重新启用。`,
|
||||
confirmText: '停用账号',
|
||||
}))) return
|
||||
setTogglingAccountID(acct.id)
|
||||
try {
|
||||
await strmAPI.updateAccount(acct.id, { provider: acct.provider, enabled: next })
|
||||
toast.success(next ? `已启用「${acct.name}」` : `已停用「${acct.name}」,远程媒体库已隐藏`)
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setTogglingAccountID(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 一键启停该账号下全部挂载(配置保留,只控制是否出现在媒体库中)。
|
||||
const setAllMounts = async (acct: StrmAccount, enabled: boolean) => {
|
||||
const acctMounts = mounts.filter((m) => m.account_id === acct.id)
|
||||
if (!enabled && !(await confirmAction({
|
||||
message: `停用「${acct.name}」下的全部 ${acctMounts.length} 个挂载?它们会立即从媒体库中隐藏,配置保留,可随时全部启用。`,
|
||||
confirmText: '全部停用',
|
||||
}))) return
|
||||
setBulkTogglingID(acct.id)
|
||||
try {
|
||||
const res = await embyAPI.setAccountMountsEnabled(acct.id, enabled)
|
||||
toast.success(enabled ? `已启用 ${res.affected} 个挂载` : `已停用 ${res.affected} 个挂载`)
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMessage(err))
|
||||
} finally {
|
||||
setBulkTogglingID(null)
|
||||
}
|
||||
}
|
||||
|
||||
const removeMount = async (mount: EmbyMount) => {
|
||||
if (!(await confirmAction({ message: `取消挂载「${mount.remote_view_name || mount.name}」?媒体库列表中将不再显示。`, confirmText: '取消挂载' }))) return
|
||||
try {
|
||||
@@ -566,26 +608,55 @@ export function EmbyMountPage() {
|
||||
) : (
|
||||
accounts.map((acct) => {
|
||||
const acctMounts = mounts.filter((m) => m.account_id === acct.id)
|
||||
const anyMountEnabled = acctMounts.some((m) => m.enabled)
|
||||
return (
|
||||
<div key={acct.id} className="overflow-hidden rounded-3xl border border-sand-200 bg-white shadow-card">
|
||||
<div
|
||||
key={acct.id}
|
||||
className={`overflow-hidden rounded-3xl border bg-white shadow-card ${
|
||||
acct.enabled ? 'border-sand-200' : 'border-gray-200 opacity-80'
|
||||
}`}
|
||||
>
|
||||
{/* 账号头部 */}
|
||||
<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">
|
||||
<span className={`flex h-10 w-10 items-center justify-center rounded-2xl ${acct.enabled ? 'bg-brand-100 text-brand-600' : 'bg-gray-100 text-sand-500'}`}>
|
||||
<Server size={20} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="font-display text-lg font-bold text-ink-600">{acct.name}</p>
|
||||
<p className="font-display text-lg font-bold text-ink-600">
|
||||
{acct.name}
|
||||
{!acct.enabled && (
|
||||
<span className="ml-2 rounded-full bg-gray-200 px-2 py-0.5 align-middle text-[11px] font-semibold text-sand-500">
|
||||
已停用
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-sand-500">
|
||||
{acct.has_credential ? '凭据已配置' : '待补全凭据'}
|
||||
{acct.emby_lines && acct.emby_lines.length > 0
|
||||
? ` · ${acct.emby_lines.length} 条线路`
|
||||
: ''}
|
||||
{acct.last_test_result ? ` · 最近测试:${acct.last_test_ok ? '正常' : acct.last_test_result}` : ''}
|
||||
{!acct.enabled ? ' · 不会发起远程请求' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => toggleAccount(acct)}
|
||||
disabled={togglingAccountID === acct.id}
|
||||
className={`iconButtonCls ${acct.enabled ? '!text-amber-600 hover:!bg-amber-50' : '!text-green-600 hover:!bg-green-50'}`}
|
||||
title={acct.enabled ? '停用该服务器:其下挂载全部从媒体库隐藏' : '重新启用该服务器'}
|
||||
>
|
||||
{togglingAccountID === acct.id ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : acct.enabled ? (
|
||||
<PowerOff size={13} />
|
||||
) : (
|
||||
<Power size={13} />
|
||||
)}
|
||||
{acct.enabled ? '停用账号' : '启用账号'}
|
||||
</button>
|
||||
<button onClick={() => testAccount(acct)} disabled={testingID === acct.id} className="iconButtonCls">
|
||||
{testingID === acct.id ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
|
||||
测试
|
||||
@@ -644,6 +715,7 @@ export function EmbyMountPage() {
|
||||
<p className="text-xs text-sand-500">
|
||||
{m.collection_type || 'mixed'}
|
||||
{m.proxy_play ? ' · 本机代理播放' : ' · 直连播放'}
|
||||
{!m.enabled ? ' · 已停用' : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -687,14 +759,41 @@ export function EmbyMountPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button onClick={() => setMountDialogFor(acct)} className="btn-outline">
|
||||
<button
|
||||
onClick={() => setMountDialogFor(acct)}
|
||||
disabled={!acct.enabled}
|
||||
className="btn-outline disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title={acct.enabled ? undefined : '账号已停用,启用后才能挂载新的媒体库'}
|
||||
>
|
||||
<Plus size={14} />
|
||||
选择挂载媒体库
|
||||
</button>
|
||||
<button onClick={() => fullMount(acct)} className="btn-outline">
|
||||
<button
|
||||
onClick={() => fullMount(acct)}
|
||||
disabled={!acct.enabled}
|
||||
className="btn-outline disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title={acct.enabled ? undefined : '账号已停用,启用后才能挂载新的媒体库'}
|
||||
>
|
||||
<Check size={14} />
|
||||
全量挂载
|
||||
</button>
|
||||
{acctMounts.length > 0 && (
|
||||
<button
|
||||
onClick={() => setAllMounts(acct, !anyMountEnabled)}
|
||||
disabled={bulkTogglingID === acct.id}
|
||||
className="btn-outline disabled:cursor-not-allowed disabled:opacity-40"
|
||||
title={anyMountEnabled ? '把该账号下全部挂载从媒体库中隐藏(配置保留)' : '恢复该账号下全部挂载'}
|
||||
>
|
||||
{bulkTogglingID === acct.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : anyMountEnabled ? (
|
||||
<PowerOff size={14} />
|
||||
) : (
|
||||
<Power size={14} />
|
||||
)}
|
||||
{anyMountEnabled ? '全部停用' : '全部启用'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user