diff --git a/internal/handler/strm.go b/internal/handler/strm.go index 01ac802..6d9ebda 100644 --- a/internal/handler/strm.go +++ b/internal/handler/strm.go @@ -8,6 +8,7 @@ import ( "net/url" "strconv" "strings" + "time" "github.com/gin-gonic/gin" @@ -30,6 +31,9 @@ type strmAccountView struct { ProviderLabel string `json:"provider_label"` // ProxyPlay 仅远程 Emby 挂载账号返回:播放流量是否经过 MMTL 代理(编辑回显用)。 ProxyPlay *bool `json:"proxy_play,omitempty"` + // EmbyLines 仅远程 Emby 挂载账号返回:多线路配置(不含凭据)。 + EmbyLines []service.EmbyRemoteLine `json:"emby_lines,omitempty"` + EmbyActiveLine int `json:"emby_active_line,omitempty"` } func strmAccountViews(svc *service.Container, accounts []model.StrmAccount) []strmAccountView { @@ -45,6 +49,10 @@ func strmAccountViews(svc *service.Container, accounts []model.StrmAccount) []st if proxyPlay, err := svc.EmbyRemote.ProxyPlayOf(&a); err == nil { view.ProxyPlay = &proxyPlay } + if lines, activeLine, err := svc.EmbyRemote.LinesOf(&a); err == nil { + view.EmbyLines = lines + view.EmbyActiveLine = activeLine + } } out = append(out, view) } @@ -117,13 +125,33 @@ func deleteStrmAccountHandler(svc *service.Container) gin.HandlerFunc { func testStrmAccountHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - acct := svc.Strm.TestStrmAccount(c.Request.Context(), c.Param("id")) - if acct == nil { + id := c.Param("id") + acct, err := svc.Repo.StrmAccount.FindByID(c.Request.Context(), id) + if err != nil || acct == nil { c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"}) return } - views := strmAccountViews(svc, []model.StrmAccount{*acct}) - c.JSON(http.StatusOK, views[0]) + now := time.Now() + acct.LastTestAt = &now + if acct.Provider == model.StrmProviderEmbyRemote && svc.EmbyRemote != nil { + if err := svc.EmbyRemote.TestConnection(c.Request.Context(), acct); err != nil { + acct.LastTestResult = err.Error() + acct.LastTestOK = false + } else { + acct.LastTestResult = "ok" + acct.LastTestOK = true + } + } else { + acct = svc.Strm.TestStrmAccount(c.Request.Context(), id) + if acct == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"}) + return + } + c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0]) + return + } + _ = svc.Repo.StrmAccount.Update(c.Request.Context(), acct) + c.JSON(http.StatusOK, strmAccountViews(svc, []model.StrmAccount{*acct})[0]) } } diff --git a/internal/service/emby_remote.go b/internal/service/emby_remote.go index d52e024..1100917 100644 --- a/internal/service/emby_remote.go +++ b/internal/service/emby_remote.go @@ -56,7 +56,9 @@ func (t *embyRemoteTransport) RoundTrip(req *http.Request) (*http.Response, erro // EmbyRemoteConfig 是一个远程 Emby 账号的解密配置。 type EmbyRemoteConfig struct { - BaseURL string // http://host:8096(无需 /emby 后缀) + BaseURL string // 当前生效线路地址(http://host:8096,无需 /emby 后缀) + Lines []EmbyRemoteLine // 全部线路,按优先级排列 + ActiveLine int // 当前生效线路下标 Username string Password string Token string // api_key(手动填写或自动认证获得) @@ -322,8 +324,14 @@ func (r *EmbyRemoteService) configOf(acct *model.StrmAccount) (*EmbyRemoteConfig return nil, fmt.Errorf("decode emby account config: %w", err) } } + lines, activeLine, err := ParseEmbyRemoteLines(raw) + if err != nil { + return nil, err + } cfg := &EmbyRemoteConfig{ - BaseURL: strings.TrimRight(strings.TrimSpace(raw["url"]), "/"), + BaseURL: lines[activeLine].URL, + Lines: lines, + ActiveLine: activeLine, Username: strings.TrimSpace(raw["username"]), Password: r.crypto.Decrypt(raw["password"]), Token: firstNonEmptyStr(r.crypto.Decrypt(raw["api_key"]), r.crypto.Decrypt(raw["token"])), @@ -333,9 +341,6 @@ func (r *EmbyRemoteService) configOf(acct *model.StrmAccount) (*EmbyRemoteConfig if cfg.BaseURL == "" { return nil, errors.New("缺少 Emby 地址") } - if !strings.HasPrefix(cfg.BaseURL, "http://") && !strings.HasPrefix(cfg.BaseURL, "https://") { - return nil, errors.New("Emby 地址必须以 http:// 或 https:// 开头") - } return cfg, nil } @@ -366,6 +371,29 @@ func (r *EmbyRemoteService) ensureToken(ctx context.Context, acct *model.StrmAcc if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" { return errors.New("缺少 Emby 凭据:请填写 api_key 或 用户名/密码") } + var lastErr error + for _, lineIdx := range r.lineOrder(cfg) { + lineCfg := r.withLine(cfg, lineIdx) + err := r.ensureTokenOnLine(ctx, acct, lineCfg) + if err == nil { + cfg.Token = lineCfg.Token + cfg.RemoteUserID = lineCfg.RemoteUserID + cfg.BaseURL = lineCfg.BaseURL + r.adoptWorkingLine(ctx, acct, cfg, lineIdx) + return r.persistToken(ctx, acct, cfg) + } + lastErr = err + if !isEmbyLineFailoverError(err) { + return err + } + } + if lastErr != nil { + return lastErr + } + return errors.New("Emby 线路认证失败") +} + +func (r *EmbyRemoteService) ensureTokenOnLine(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig) error { body, _ := json.Marshal(map[string]string{"Username": cfg.Username, "Pw": cfg.Password}) endpoint := r.embyBase(cfg) + "/Users/AuthenticateByName" req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(body))) @@ -399,7 +427,7 @@ func (r *EmbyRemoteService) ensureToken(ctx context.Context, acct *model.StrmAcc if login.User.Id != "" { cfg.RemoteUserID = login.User.Id } - return r.persistToken(ctx, acct, cfg) + return nil } // persistToken 把认证得到的 token / user id 加密写回账号配置(下次请求免登录)。 @@ -425,12 +453,36 @@ func (r *EmbyRemoteService) persistToken(ctx context.Context, acct *model.StrmAc } // doGet 向远程 Emby 发起带 api_key 的 GET,把响应 JSON 解码到 out。 -// 401 时自动重认证一次再重试(凭据过期场景)。 +// 401 时自动重认证一次再重试(凭据过期场景)。连接失败时按线路优先级自动切换。 func (r *EmbyRemoteService) doGet(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, path string, q url.Values, out any) error { - for attempt := 0; attempt < 2; attempt++ { - if err := r.ensureToken(ctx, acct, cfg); err != nil { + var lastErr error + for _, lineIdx := range r.lineOrder(cfg) { + lineCfg := r.withLine(cfg, lineIdx) + err := r.doGetOnLine(ctx, acct, cfg, lineCfg, path, q, out) + if err == nil { + r.adoptWorkingLine(ctx, acct, cfg, lineIdx) + return nil + } + lastErr = err + if !isEmbyLineFailoverError(err) { return err } + } + if lastErr != nil { + return lastErr + } + return errors.New("远程 Emby 请求失败") +} + +func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAccount, master *EmbyRemoteConfig, cfg *EmbyRemoteConfig, path string, q url.Values, out any) error { + for attempt := 0; attempt < 2; attempt++ { + if strings.TrimSpace(cfg.Token) == "" { + if err := r.ensureTokenOnLine(ctx, acct, cfg); err != nil { + return err + } + master.Token = cfg.Token + master.RemoteUserID = cfg.RemoteUserID + } endpoint := r.embyBase(cfg) + path if q != nil { endpoint += "?" + q.Encode() @@ -452,8 +504,8 @@ func (r *EmbyRemoteService) doGet(ctx context.Context, acct *model.StrmAccount, return readErr } if resp.StatusCode == http.StatusUnauthorized && attempt == 0 { - // token 失效:清空后重认证重试一次。 cfg.Token = "" + master.Token = "" if acct != nil { raw := map[string]string{} _ = json.Unmarshal([]byte(acct.Config), &raw) @@ -783,9 +835,39 @@ func (r *EmbyRemoteService) ProxyVideoStream(ctx context.Context, w http.Respons if err != nil { return err } - if err := r.ensureToken(ctx, acct, cfg); err != nil { - return err + var lastErr error + for _, lineIdx := range r.lineOrder(cfg) { + lineCfg := r.withLine(cfg, lineIdx) + lineCfg.Token = cfg.Token + lineCfg.RemoteUserID = cfg.RemoteUserID + if strings.TrimSpace(lineCfg.Token) == "" { + if err := r.ensureToken(ctx, acct, lineCfg); err != nil { + lastErr = err + if isEmbyLineFailoverError(err) { + continue + } + return err + } + cfg.Token = lineCfg.Token + cfg.RemoteUserID = lineCfg.RemoteUserID + } + err := r.proxyVideoStreamOnLine(ctx, w, req, lineCfg, remoteID) + if err == nil { + r.adoptWorkingLine(ctx, acct, cfg, lineIdx) + return nil + } + lastErr = err + if !isEmbyLineFailoverError(err) { + return err + } } + if lastErr != nil { + return lastErr + } + return errors.New("远程 Emby 视频流请求失败") +} + +func (r *EmbyRemoteService) proxyVideoStreamOnLine(ctx context.Context, w http.ResponseWriter, req *http.Request, cfg *EmbyRemoteConfig, remoteID string) error { endpoint := r.embyBase(cfg) + "/Videos/" + url.PathEscape(remoteID) + "/stream" q := url.Values{} if mediaSourceID := strings.TrimSpace(req.URL.Query().Get("MediaSourceId")); mediaSourceID != "" { @@ -837,9 +919,39 @@ func (r *EmbyRemoteService) ProxySubtitle(ctx context.Context, w http.ResponseWr if err != nil { return err } - if err := r.ensureToken(ctx, acct, cfg); err != nil { - return err + var lastErr error + for _, lineIdx := range r.lineOrder(cfg) { + lineCfg := r.withLine(cfg, lineIdx) + lineCfg.Token = cfg.Token + lineCfg.RemoteUserID = cfg.RemoteUserID + if strings.TrimSpace(lineCfg.Token) == "" { + if err := r.ensureToken(ctx, acct, lineCfg); err != nil { + lastErr = err + if isEmbyLineFailoverError(err) { + continue + } + return err + } + cfg.Token = lineCfg.Token + cfg.RemoteUserID = lineCfg.RemoteUserID + } + err := r.proxySubtitleOnLine(ctx, w, lineCfg, remoteID, index) + if err == nil { + r.adoptWorkingLine(ctx, acct, cfg, lineIdx) + return nil + } + lastErr = err + if !isEmbyLineFailoverError(err) { + return err + } } + if lastErr != nil { + return lastErr + } + return errors.New("远程 Emby 字幕流请求失败") +} + +func (r *EmbyRemoteService) proxySubtitleOnLine(ctx context.Context, w http.ResponseWriter, cfg *EmbyRemoteConfig, remoteID, index string) error { endpoint := r.embyBase(cfg) + "/Videos/" + url.PathEscape(remoteID) + "/Subtitles/" + url.PathEscape(index) + "/Stream" endpoint += "?api_key=" + url.QueryEscape(cfg.Token) upstream, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) @@ -902,6 +1014,39 @@ func (r *EmbyRemoteService) ProxySetFavorite(ctx context.Context, acct *model.St } func (r *EmbyRemoteService) doMutate(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, method, path string) error { + var lastErr error + for _, lineIdx := range r.lineOrder(cfg) { + lineCfg := r.withLine(cfg, lineIdx) + lineCfg.Token = cfg.Token + lineCfg.RemoteUserID = cfg.RemoteUserID + if strings.TrimSpace(lineCfg.Token) == "" { + if err := r.ensureToken(ctx, acct, lineCfg); err != nil { + lastErr = err + if isEmbyLineFailoverError(err) { + continue + } + return err + } + cfg.Token = lineCfg.Token + cfg.RemoteUserID = lineCfg.RemoteUserID + } + err := r.doMutateOnLine(ctx, lineCfg, method, path) + if err == nil { + r.adoptWorkingLine(ctx, acct, cfg, lineIdx) + return nil + } + lastErr = err + if !isEmbyLineFailoverError(err) { + return err + } + } + if lastErr != nil { + return lastErr + } + return errors.New("远程 Emby 状态同步失败") +} + +func (r *EmbyRemoteService) doMutateOnLine(ctx context.Context, cfg *EmbyRemoteConfig, method, path string) error { endpoint := r.embyBase(cfg) + path + "?api_key=" + url.QueryEscape(cfg.Token) req, err := http.NewRequestWithContext(ctx, method, endpoint, nil) if err != nil { diff --git a/internal/service/emby_remote_lines.go b/internal/service/emby_remote_lines.go new file mode 100644 index 0000000..223a4de --- /dev/null +++ b/internal/service/emby_remote_lines.go @@ -0,0 +1,185 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/ShukeBta/MMTL/internal/model" +) + +// EmbyRemoteLine 表示同一 Emby 服务器的一条接入线路(内网/外网/CDN 等)。 +type EmbyRemoteLine struct { + Name string `json:"name"` + URL string `json:"url"` +} + +func normalizeEmbyRemoteURL(raw string) string { + u := strings.TrimRight(strings.TrimSpace(raw), "/") + if u == "" { + return "" + } + if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { + return "" + } + return u +} + +// NormalizeEmbyRemoteLines 去掉空 URL,并规范每条线路地址。 +func NormalizeEmbyRemoteLines(lines []EmbyRemoteLine) []EmbyRemoteLine { + out := make([]EmbyRemoteLine, 0, len(lines)) + for _, line := range lines { + u := normalizeEmbyRemoteURL(line.URL) + if u == "" { + continue + } + out = append(out, EmbyRemoteLine{ + Name: strings.TrimSpace(line.Name), + URL: u, + }) + } + return out +} + +// ParseEmbyRemoteLines 从账号配置 JSON 中解析线路列表。 +// 兼容旧版单字段 url;urls 为 JSON 数组字符串。 +func ParseEmbyRemoteLines(raw map[string]string) ([]EmbyRemoteLine, int, error) { + active := 0 + if v := strings.TrimSpace(raw["active_line"]); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + active = n + } + } + if urlsJSON := strings.TrimSpace(raw["urls"]); urlsJSON != "" { + var lines []EmbyRemoteLine + if err := json.Unmarshal([]byte(urlsJSON), &lines); err != nil { + return nil, 0, fmt.Errorf("解析 Emby 线路列表: %w", err) + } + lines = NormalizeEmbyRemoteLines(lines) + if len(lines) == 0 { + return nil, 0, errors.New("至少配置一条 Emby 线路") + } + if active >= len(lines) { + active = 0 + } + return lines, active, nil + } + if u := normalizeEmbyRemoteURL(raw["url"]); u != "" { + return []EmbyRemoteLine{{URL: u}}, 0, nil + } + return nil, 0, errors.New("缺少 Emby 地址") +} + +// EncodeEmbyRemoteLines 序列化线路列表,并返回兼容旧版的 primary URL。 +func EncodeEmbyRemoteLines(lines []EmbyRemoteLine) (urlsJSON string, primaryURL string, err error) { + lines = NormalizeEmbyRemoteLines(lines) + if len(lines) == 0 { + return "", "", errors.New("至少配置一条 Emby 线路") + } + data, err := json.Marshal(lines) + if err != nil { + return "", "", err + } + return string(data), lines[0].URL, nil +} + +func (r *EmbyRemoteService) withLine(cfg *EmbyRemoteConfig, index int) *EmbyRemoteConfig { + if cfg == nil { + return nil + } + copy := *cfg + if index >= 0 && index < len(cfg.Lines) { + copy.BaseURL = normalizeEmbyRemoteURL(cfg.Lines[index].URL) + copy.ActiveLine = index + } + return © +} + +func (r *EmbyRemoteService) lineOrder(cfg *EmbyRemoteConfig) []int { + if cfg == nil || len(cfg.Lines) == 0 { + return []int{0} + } + if len(cfg.Lines) == 1 { + return []int{0} + } + active := cfg.ActiveLine + if active < 0 || active >= len(cfg.Lines) { + active = 0 + } + order := []int{active} + for i := range cfg.Lines { + if i != active { + order = append(order, i) + } + } + return order +} + +func isEmbyLineFailoverError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + if strings.Contains(msg, "登录失败") || + strings.Contains(msg, "未返回 accesstoken") || + strings.Contains(msg, "缺少 emby 凭据") || + strings.Contains(msg, "认证重试失败") { + return false + } + var urlErr *url.Error + if errors.As(err, &urlErr) { + return true + } + return strings.Contains(msg, "连接远程 emby") || + strings.Contains(msg, "请求远程 emby 失败") || + strings.Contains(msg, "dial tcp") || + strings.Contains(msg, "connection refused") || + strings.Contains(msg, "no such host") || + strings.Contains(msg, "timeout") || + strings.Contains(msg, "i/o timeout") +} + +func (r *EmbyRemoteService) persistActiveLine(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, lineIndex int) error { + if acct == nil || cfg == nil || lineIndex < 0 || lineIndex >= len(cfg.Lines) { + return nil + } + raw := map[string]string{} + if strings.TrimSpace(acct.Config) != "" { + _ = json.Unmarshal([]byte(acct.Config), &raw) + } + raw["active_line"] = strconv.Itoa(lineIndex) + raw["url"] = cfg.Lines[lineIndex].URL + data, err := json.Marshal(raw) + if err != nil { + return err + } + acct.Config = string(data) + cfg.ActiveLine = lineIndex + cfg.BaseURL = normalizeEmbyRemoteURL(cfg.Lines[lineIndex].URL) + return r.repo.StrmAccount.Update(ctx, acct) +} + +func (r *EmbyRemoteService) adoptWorkingLine(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, lineIndex int) { + if cfg == nil || lineIndex < 0 || lineIndex >= len(cfg.Lines) { + return + } + if lineIndex == cfg.ActiveLine { + return + } + _ = r.persistActiveLine(ctx, acct, cfg, lineIndex) +} + +// LinesOf 返回账号已配置的线路(供管理界面回显,不含敏感信息)。 +func (r *EmbyRemoteService) LinesOf(acct *model.StrmAccount) ([]EmbyRemoteLine, int, error) { + raw := map[string]string{} + if acct != nil && strings.TrimSpace(acct.Config) != "" { + if err := json.Unmarshal([]byte(acct.Config), &raw); err != nil { + return nil, 0, err + } + } + return ParseEmbyRemoteLines(raw) +} diff --git a/internal/service/emby_remote_lines_test.go b/internal/service/emby_remote_lines_test.go new file mode 100644 index 0000000..87a1a49 --- /dev/null +++ b/internal/service/emby_remote_lines_test.go @@ -0,0 +1,66 @@ +package service + +import ( + "testing" +) + +func TestParseEmbyRemoteLinesLegacyURL(t *testing.T) { + lines, active, err := ParseEmbyRemoteLines(map[string]string{ + "url": "http://192.168.1.10:8096/", + }) + if err != nil { + t.Fatalf("ParseEmbyRemoteLines() error = %v", err) + } + if active != 0 || len(lines) != 1 || lines[0].URL != "http://192.168.1.10:8096" { + t.Fatalf("unexpected lines: %#v active=%d", lines, active) + } +} + +func TestParseEmbyRemoteLinesMulti(t *testing.T) { + lines, active, err := ParseEmbyRemoteLines(map[string]string{ + "urls": `[{"name":"内网","url":"http://10.0.0.8:8096"},{"name":"外网","url":"https://emby.example.com"}]`, + "active_line": "1", + }) + if err != nil { + t.Fatalf("ParseEmbyRemoteLines() error = %v", err) + } + if active != 1 || len(lines) != 2 { + t.Fatalf("unexpected lines: %#v active=%d", lines, active) + } + if lines[1].Name != "外网" || lines[1].URL != "https://emby.example.com" { + t.Fatalf("unexpected second line: %#v", lines[1]) + } +} + +func TestEncodeEmbyRemoteLines(t *testing.T) { + jsonText, primary, err := EncodeEmbyRemoteLines([]EmbyRemoteLine{ + {Name: "内网", URL: "http://10.0.0.8:8096/"}, + {Name: "外网", URL: "https://emby.example.com"}, + }) + if err != nil { + t.Fatalf("EncodeEmbyRemoteLines() error = %v", err) + } + if primary != "http://10.0.0.8:8096" { + t.Fatalf("primary = %q", primary) + } + lines, active, err := ParseEmbyRemoteLines(map[string]string{"urls": jsonText}) + if err != nil || active != 0 || len(lines) != 2 { + t.Fatalf("roundtrip failed: %#v active=%d err=%v", lines, active, err) + } +} + +func TestEmbyRemoteServiceLineOrder(t *testing.T) { + svc := &EmbyRemoteService{} + cfg := &EmbyRemoteConfig{ + Lines: []EmbyRemoteLine{ + {URL: "http://a"}, + {URL: "http://b"}, + {URL: "http://c"}, + }, + ActiveLine: 1, + } + order := svc.lineOrder(cfg) + if len(order) != 3 || order[0] != 1 || order[1] != 0 || order[2] != 2 { + t.Fatalf("unexpected order: %#v", order) + } +} diff --git a/internal/service/strm_service.go b/internal/service/strm_service.go index b4bc05a..47f4c08 100644 --- a/internal/service/strm_service.go +++ b/internal/service/strm_service.go @@ -273,8 +273,9 @@ func HasStrmAccountCredential(acct *model.StrmAccount) bool { case model.StrmProviderOpenList: return strings.Contains(acct.Config, `"token"`) || strings.Contains(acct.Config, `"password"`) case model.StrmProviderEmbyRemote: - // 远程 Emby:接入地址 + (自动认证凭据 或 手动 api_key) 即视为已配置。 - return strings.Contains(acct.Config, `"url"`) && + // 远程 Emby:接入线路 + (自动认证凭据 或 手动 api_key) 即视为已配置。 + hasURL := strings.Contains(acct.Config, `"url"`) || strings.Contains(acct.Config, `"urls"`) + return hasURL && (strings.Contains(acct.Config, `"token"`) || strings.Contains(acct.Config, `"api_key"`) || (strings.Contains(acct.Config, `"username"`) && strings.Contains(acct.Config, `"password"`))) default: diff --git a/web/src/pages/EmbyMountPage.tsx b/web/src/pages/EmbyMountPage.tsx index 0081759..dc5daaf 100644 --- a/web/src/pages/EmbyMountPage.tsx +++ b/web/src/pages/EmbyMountPage.tsx @@ -25,6 +25,12 @@ import type { StrmAccount } from '../types/strm' import type { EmbyMount, RemoteEmbyView } from '../types/emby' import { apiErrorMessage } from './StrmManagePage' import { confirmAction } from '../components/confirmAction' +import { + defaultEmbyRemoteLines, + encodeEmbyRemoteConfigLines, + normalizeEmbyRemoteLines, + type EmbyRemoteLine, +} from '../utils/embyRemoteLines' const inputCls = 'input-base w-full' @@ -40,21 +46,50 @@ function AccountDialog({ onSaved: () => void }) { const [name, setName] = useState(existing?.name ?? '') - const [url, setUrl] = useState('') + const [lines, setLines] = useState(() => defaultEmbyRemoteLines(existing?.emby_lines)) 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 normalizedLines = normalizeEmbyRemoteLines(lines) + const canSave = () => normalizedLines.length > 0 && (token !== '' || password !== '' || !!existing?.has_credential) + + const updateLine = (index: number, patch: Partial) => { + setLines((prev) => prev.map((line, i) => (i === index ? { ...line, ...patch } : line))) + } + + const addLine = () => { + setLines((prev) => [...prev, { name: `线路 ${prev.length + 1}`, url: '' }]) + } + + const removeLine = (index: number) => { + setLines((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index))) + } + + const moveLine = (index: number, direction: 'up' | 'down') => { + setLines((prev) => { + const target = direction === 'up' ? index - 1 : index + 1 + if (target < 0 || target >= prev.length) return prev + const next = [...prev] + const [moved] = next.splice(index, 1) + next.splice(target, 0, moved) + return next + }) + } const submit = async (event: React.FormEvent) => { event.preventDefault() + const readyLines = normalizeEmbyRemoteLines(lines) + if (readyLines.length === 0) { + toast.error('请至少填写一条有效的 Emby 线路地址') + return + } setSaving(true) try { const config: Record = { - ...(url ? { url } : {}), + ...encodeEmbyRemoteConfigLines(readyLines), ...(username ? { username } : {}), ...(password ? { password } : {}), ...(token ? { token } : {}), @@ -69,7 +104,7 @@ function AccountDialog({ toast.success('Emby 账号已更新') } else { if (!canSave()) { - toast.error('请填写 Emby 地址与密码(或 API Key)') + toast.error('请填写线路地址与密码(或 API Key)') return } await strmAPI.createAccount({ name: name || 'Emby 服务器', provider: 'emby_remote', config }) @@ -85,11 +120,11 @@ function AccountDialog({ return (
-
e.stopPropagation()}> +
e.stopPropagation()}>

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

-

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

+

可配置内网、外网等多条线路,连接失败时按顺序自动切换

+
+ {lines.map((line, index) => ( +
+
+ + 线路 {index + 1} + {existing?.emby_active_line === index ? ' · 当前优先' : index === 0 ? ' · 默认优先' : ''} + +
+ + + +
+
+ updateLine(index, { name: e.target.value })} + /> + updateLine(index, { url: e.target.value })} + /> +
+ ))} +
diff --git a/web/src/pages/StrmDialogs.tsx b/web/src/pages/StrmDialogs.tsx index 56c3291..5d81fa4 100644 --- a/web/src/pages/StrmDialogs.tsx +++ b/web/src/pages/StrmDialogs.tsx @@ -1,15 +1,19 @@ import { FormEvent, useEffect, useMemo, useRef, useState } from 'react' import toast from 'react-hot-toast' import { + ArrowDown, + ArrowUp, ChevronRight, Cloud, ExternalLink, FolderPlus, HardDrive, Loader2, + Plus, QrCode, RefreshCw, Search, + Trash2, Tv, X, } from 'lucide-react' @@ -23,6 +27,12 @@ import type { StrmAccount, StrmSyncPath, StrmSyncPathInput, StrmProvider } from import { STRM_PROVIDER_LABELS } from '../types/strm' import { apiErrorMessage } from './StrmManagePage' import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog' +import { + defaultEmbyRemoteLines, + encodeEmbyRemoteConfigLines, + normalizeEmbyRemoteLines, + type EmbyRemoteLine, +} from '../utils/embyRemoteLines' // ─── 弹框外壳 ──────────────────────────────────────────────────────────────── @@ -106,6 +116,9 @@ export function StrmAccountDialog({ const [name, setName] = useState(existing?.name ?? '') const [enabled, setEnabled] = useState(existing?.enabled ?? true) const [url, setUrl] = useState('') + const [embyLines, setEmbyLines] = useState(() => + defaultEmbyRemoteLines(existing?.provider === 'emby_remote' ? existing?.emby_lines : undefined), + ) const [server, setServer] = useState('') const [username, setUsername] = useState('') const [password, setPassword] = useState('') @@ -128,14 +141,16 @@ export function StrmAccountDialog({ ...(password ? { password } : {}), ...(token ? { token } : {}), } - case 'emby_remote': + case 'emby_remote': { + const lineConfig = encodeEmbyRemoteConfigLines(embyLines) return { - ...(url ? { url } : {}), + ...lineConfig, ...(username ? { username } : {}), ...(password ? { password } : {}), ...(token ? { token } : {}), proxy_play: proxyPlay ? 'true' : 'false', } + } default: return {} } @@ -148,7 +163,7 @@ export function StrmAccountDialog({ case 'openlist': return server !== '' && (token !== '' || password !== '') case 'emby_remote': - return url !== '' && (token !== '' || password !== '') + return normalizeEmbyRemoteLines(embyLines).length > 0 && (token !== '' || password !== '') default: return false } @@ -255,9 +270,92 @@ export function StrmAccountDialog({ )} {provider === 'emby_remote' && ( - - setUrl(e.target.value)} /> - +
+
+
+

接入线路

+

可配置内网、外网等多条线路,连接失败时按顺序自动切换

+
+ +
+ {embyLines.map((line, index) => ( +
+
+ + 线路 {index + 1} + {existing?.emby_active_line === index ? ' · 当前优先' : index === 0 ? ' · 默认优先' : ''} + +
+ + + +
+
+ + setEmbyLines((prev) => prev.map((item, i) => (i === index ? { ...item, name: e.target.value } : item))) + } + /> + + setEmbyLines((prev) => prev.map((item, i) => (i === index ? { ...item, url: e.target.value } : item))) + } + /> +
+ ))} +
)} {(provider === 'clouddrive2' || provider === 'emby_remote') && ( diff --git a/web/src/types/strm.ts b/web/src/types/strm.ts index 709ce9d..745517b 100644 --- a/web/src/types/strm.ts +++ b/web/src/types/strm.ts @@ -9,6 +9,11 @@ export const STRM_PROVIDER_LABELS: Record = { emby_remote: 'Emby 远程挂载', } +export type EmbyRemoteLine = { + name: string + url: string +} + export interface StrmAccount { id: string name: string @@ -23,6 +28,10 @@ export interface StrmAccount { provider_label: string /** 仅远程 Emby 挂载账号返回:播放流量是否经过 MMTL 代理 */ proxy_play?: boolean + /** 仅远程 Emby 挂载账号返回:多线路配置 */ + emby_lines?: EmbyRemoteLine[] + /** 当前优先使用的线路下标 */ + emby_active_line?: number } export interface StrmAccountInput { diff --git a/web/src/utils/embyRemoteLines.ts b/web/src/utils/embyRemoteLines.ts new file mode 100644 index 0000000..6a0e791 --- /dev/null +++ b/web/src/utils/embyRemoteLines.ts @@ -0,0 +1,31 @@ +export type EmbyRemoteLine = { + name: string + url: string +} + +export function normalizeEmbyRemoteLines(lines: EmbyRemoteLine[]): EmbyRemoteLine[] { + return lines + .map((line) => ({ + name: line.name.trim(), + url: line.url.trim().replace(/\/+$/, ''), + })) + .filter((line) => line.url.startsWith('http://') || line.url.startsWith('https://')) +} + +export function encodeEmbyRemoteConfigLines(lines: EmbyRemoteLine[]): Record { + const normalized = normalizeEmbyRemoteLines(lines) + if (normalized.length === 0) { + return {} + } + return { + urls: JSON.stringify(normalized), + url: normalized[0].url, + } +} + +export function defaultEmbyRemoteLines(existing?: EmbyRemoteLine[]): EmbyRemoteLine[] { + if (existing && existing.length > 0) { + return existing.map((line) => ({ name: line.name ?? '', url: line.url ?? '' })) + } + return [{ name: '线路 1', url: '' }] +}