From 91667c400c092d9d14fef0cd7e9c9486702363c5 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:37:31 +0800 Subject: [PATCH] =?UTF-8?q?bug=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/emby_mounts.go | 26 +++ internal/handler/emby_mounts_enabled_test.go | 108 ++++++++++++ internal/handler/routes_admin.go | 1 + internal/repository/emby_mount_repository.go | 17 ++ internal/service/emby_remote.go | 68 +++++++- .../service/emby_remote_views_cache_test.go | 155 ++++++++++++++++++ internal/service/emby_remote_web.go | 6 + web/src/api/emby.ts | 4 + web/src/pages/EmbyMountPage.tsx | 109 +++++++++++- 9 files changed, 488 insertions(+), 6 deletions(-) create mode 100644 internal/handler/emby_mounts_enabled_test.go create mode 100644 internal/service/emby_remote_views_cache_test.go diff --git a/internal/handler/emby_mounts.go b/internal/handler/emby_mounts.go index 7bb40b2..93b842e 100644 --- a/internal/handler/emby_mounts.go +++ b/internal/handler/emby_mounts.go @@ -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"` } diff --git a/internal/handler/emby_mounts_enabled_test.go b/internal/handler/emby_mounts_enabled_test.go new file mode 100644 index 0000000..b5aa711 --- /dev/null +++ b/internal/handler/emby_mounts_enabled_test.go @@ -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()) + } +} diff --git a/internal/handler/routes_admin.go b/internal/handler/routes_admin.go index e655d63..3bf765e 100644 --- a/internal/handler/routes_admin.go +++ b/internal/handler/routes_admin.go @@ -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)) diff --git a/internal/repository/emby_mount_repository.go b/internal/repository/emby_mount_repository.go index c96c11f..17f725a 100644 --- a/internal/repository/emby_mount_repository.go +++ b/internal/repository/emby_mount_repository.go @@ -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 diff --git a/internal/service/emby_remote.go b/internal/service/emby_remote.go index 32d7436..320ee4b 100644 --- a/internal/service/emby_remote.go +++ b/internal/service/emby_remote.go @@ -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 diff --git a/internal/service/emby_remote_views_cache_test.go b/internal/service/emby_remote_views_cache_test.go new file mode 100644 index 0000000..fd696f2 --- /dev/null +++ b/internal/service/emby_remote_views_cache_test.go @@ -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") + } +} diff --git a/internal/service/emby_remote_web.go b/internal/service/emby_remote_web.go index 80e6180..313fa52 100644 --- a/internal/service/emby_remote_web.go +++ b/internal/service/emby_remote_web.go @@ -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 diff --git a/web/src/api/emby.ts b/web/src/api/emby.ts index 78cfff1..7b56413 100644 --- a/web/src/api/emby.ts +++ b/web/src/api/emby.ts @@ -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(`/admin/emby/accounts/${accountId}/views`).then((r) => r.data), fullMountAccount: (accountId: string, proxy: boolean) => diff --git a/web/src/pages/EmbyMountPage.tsx b/web/src/pages/EmbyMountPage.tsx index f507097..544e496 100644 --- a/web/src/pages/EmbyMountPage.tsx +++ b/web/src/pages/EmbyMountPage.tsx @@ -370,6 +370,8 @@ export function EmbyMountPage() { const [accountDialog, setAccountDialog] = useState(null) const [mountDialogFor, setMountDialogFor] = useState(null) const [testingID, setTestingID] = useState(null) + const [togglingAccountID, setTogglingAccountID] = useState(null) + const [bulkTogglingID, setBulkTogglingID] = useState(null) const [draggingId, setDraggingId] = useState(null) const [dragOverId, setDragOverId] = useState(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 ( -
+
{/* 账号头部 */}
- +
-

{acct.name}

+

+ {acct.name} + {!acct.enabled && ( + + 已停用 + + )} +

{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 ? ' · 不会发起远程请求' : ''}

+
@@ -687,14 +759,41 @@ export function EmbyMountPage() {
)}
- - + {acctMounts.length > 0 && ( + + )}