feat(emby): support multiple connection lines for remote mount (#9)

Add account-level multi-line configuration for emby_remote mounts.
Lines are stored in strm_accounts.config as a urls JSON array with
backward compatibility for the legacy single url field. The backend
automatically fails over to the next line on connection errors and
persists the working active_line index.

Frontend: multi-line editor on Emby mount page and STRM account dialog
with add/remove/reorder, line names, and URL validation.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
This commit is contained in:
truewhile
2026-09-02 11:19:56 +08:00
committed by GitHub
parent 543615a2a8
commit 97b3a7bd7f
9 changed files with 676 additions and 43 deletions
+32 -4
View File
@@ -8,6 +8,7 @@ import (
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -30,6 +31,9 @@ type strmAccountView struct {
ProviderLabel string `json:"provider_label"` ProviderLabel string `json:"provider_label"`
// ProxyPlay 仅远程 Emby 挂载账号返回:播放流量是否经过 MMTL 代理(编辑回显用)。 // ProxyPlay 仅远程 Emby 挂载账号返回:播放流量是否经过 MMTL 代理(编辑回显用)。
ProxyPlay *bool `json:"proxy_play,omitempty"` 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 { 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 { if proxyPlay, err := svc.EmbyRemote.ProxyPlayOf(&a); err == nil {
view.ProxyPlay = &proxyPlay view.ProxyPlay = &proxyPlay
} }
if lines, activeLine, err := svc.EmbyRemote.LinesOf(&a); err == nil {
view.EmbyLines = lines
view.EmbyActiveLine = activeLine
}
} }
out = append(out, view) out = append(out, view)
} }
@@ -117,13 +125,33 @@ func deleteStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
func testStrmAccountHandler(svc *service.Container) gin.HandlerFunc { func testStrmAccountHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
acct := svc.Strm.TestStrmAccount(c.Request.Context(), c.Param("id")) id := c.Param("id")
if acct == nil { acct, err := svc.Repo.StrmAccount.FindByID(c.Request.Context(), id)
if err != nil || acct == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"}) c.JSON(http.StatusNotFound, gin.H{"error": "网盘账号不存在"})
return return
} }
views := strmAccountViews(svc, []model.StrmAccount{*acct}) now := time.Now()
c.JSON(http.StatusOK, views[0]) 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])
} }
} }
+159 -14
View File
@@ -56,7 +56,9 @@ func (t *embyRemoteTransport) RoundTrip(req *http.Request) (*http.Response, erro
// EmbyRemoteConfig 是一个远程 Emby 账号的解密配置。 // EmbyRemoteConfig 是一个远程 Emby 账号的解密配置。
type EmbyRemoteConfig struct { type EmbyRemoteConfig struct {
BaseURL string // http://host:8096(无需 /emby 后缀) BaseURL string // 当前生效线路地址(http://host:8096,无需 /emby 后缀)
Lines []EmbyRemoteLine // 全部线路,按优先级排列
ActiveLine int // 当前生效线路下标
Username string Username string
Password string Password string
Token string // api_key(手动填写或自动认证获得) 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) return nil, fmt.Errorf("decode emby account config: %w", err)
} }
} }
lines, activeLine, err := ParseEmbyRemoteLines(raw)
if err != nil {
return nil, err
}
cfg := &EmbyRemoteConfig{ cfg := &EmbyRemoteConfig{
BaseURL: strings.TrimRight(strings.TrimSpace(raw["url"]), "/"), BaseURL: lines[activeLine].URL,
Lines: lines,
ActiveLine: activeLine,
Username: strings.TrimSpace(raw["username"]), Username: strings.TrimSpace(raw["username"]),
Password: r.crypto.Decrypt(raw["password"]), Password: r.crypto.Decrypt(raw["password"]),
Token: firstNonEmptyStr(r.crypto.Decrypt(raw["api_key"]), r.crypto.Decrypt(raw["token"])), 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 == "" { if cfg.BaseURL == "" {
return nil, errors.New("缺少 Emby 地址") 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 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) == "" { if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" {
return errors.New("缺少 Emby 凭据:请填写 api_key 或 用户名/密码") 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}) body, _ := json.Marshal(map[string]string{"Username": cfg.Username, "Pw": cfg.Password})
endpoint := r.embyBase(cfg) + "/Users/AuthenticateByName" endpoint := r.embyBase(cfg) + "/Users/AuthenticateByName"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(body))) 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 != "" { if login.User.Id != "" {
cfg.RemoteUserID = login.User.Id cfg.RemoteUserID = login.User.Id
} }
return r.persistToken(ctx, acct, cfg) return nil
} }
// persistToken 把认证得到的 token / user id 加密写回账号配置(下次请求免登录)。 // 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。 // 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 { 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++ { var lastErr error
if err := r.ensureToken(ctx, acct, cfg); err != nil { 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 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 endpoint := r.embyBase(cfg) + path
if q != nil { if q != nil {
endpoint += "?" + q.Encode() endpoint += "?" + q.Encode()
@@ -452,8 +504,8 @@ func (r *EmbyRemoteService) doGet(ctx context.Context, acct *model.StrmAccount,
return readErr return readErr
} }
if resp.StatusCode == http.StatusUnauthorized && attempt == 0 { if resp.StatusCode == http.StatusUnauthorized && attempt == 0 {
// token 失效:清空后重认证重试一次。
cfg.Token = "" cfg.Token = ""
master.Token = ""
if acct != nil { if acct != nil {
raw := map[string]string{} raw := map[string]string{}
_ = json.Unmarshal([]byte(acct.Config), &raw) _ = json.Unmarshal([]byte(acct.Config), &raw)
@@ -783,9 +835,39 @@ func (r *EmbyRemoteService) ProxyVideoStream(ctx context.Context, w http.Respons
if err != nil { if err != nil {
return err return err
} }
if err := r.ensureToken(ctx, acct, cfg); err != nil { var lastErr error
return err 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" endpoint := r.embyBase(cfg) + "/Videos/" + url.PathEscape(remoteID) + "/stream"
q := url.Values{} q := url.Values{}
if mediaSourceID := strings.TrimSpace(req.URL.Query().Get("MediaSourceId")); mediaSourceID != "" { 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 { if err != nil {
return err return err
} }
if err := r.ensureToken(ctx, acct, cfg); err != nil { var lastErr error
return err 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 := r.embyBase(cfg) + "/Videos/" + url.PathEscape(remoteID) + "/Subtitles/" + url.PathEscape(index) + "/Stream"
endpoint += "?api_key=" + url.QueryEscape(cfg.Token) endpoint += "?api_key=" + url.QueryEscape(cfg.Token)
upstream, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) 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 { 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) endpoint := r.embyBase(cfg) + path + "?api_key=" + url.QueryEscape(cfg.Token)
req, err := http.NewRequestWithContext(ctx, method, endpoint, nil) req, err := http.NewRequestWithContext(ctx, method, endpoint, nil)
if err != nil { if err != nil {
+185
View File
@@ -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 &copy
}
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)
}
@@ -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)
}
}
+3 -2
View File
@@ -273,8 +273,9 @@ func HasStrmAccountCredential(acct *model.StrmAccount) bool {
case model.StrmProviderOpenList: case model.StrmProviderOpenList:
return strings.Contains(acct.Config, `"token"`) || strings.Contains(acct.Config, `"password"`) return strings.Contains(acct.Config, `"token"`) || strings.Contains(acct.Config, `"password"`)
case model.StrmProviderEmbyRemote: case model.StrmProviderEmbyRemote:
// 远程 Emby:接入地址 + (自动认证凭据 或 手动 api_key) 即视为已配置。 // 远程 Emby:接入线路 + (自动认证凭据 或 手动 api_key) 即视为已配置。
return strings.Contains(acct.Config, `"url"`) && 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, `"token"`) || strings.Contains(acct.Config, `"api_key"`) ||
(strings.Contains(acct.Config, `"username"`) && strings.Contains(acct.Config, `"password"`))) (strings.Contains(acct.Config, `"username"`) && strings.Contains(acct.Config, `"password"`)))
default: default:
+87 -17
View File
@@ -25,6 +25,12 @@ import type { StrmAccount } from '../types/strm'
import type { EmbyMount, RemoteEmbyView } from '../types/emby' import type { EmbyMount, RemoteEmbyView } from '../types/emby'
import { apiErrorMessage } from './StrmManagePage' import { apiErrorMessage } from './StrmManagePage'
import { confirmAction } from '../components/confirmAction' import { confirmAction } from '../components/confirmAction'
import {
defaultEmbyRemoteLines,
encodeEmbyRemoteConfigLines,
normalizeEmbyRemoteLines,
type EmbyRemoteLine,
} from '../utils/embyRemoteLines'
const inputCls = 'input-base w-full' const inputCls = 'input-base w-full'
@@ -40,21 +46,50 @@ function AccountDialog({
onSaved: () => void onSaved: () => void
}) { }) {
const [name, setName] = useState(existing?.name ?? '') const [name, setName] = useState(existing?.name ?? '')
const [url, setUrl] = useState('') const [lines, setLines] = useState<EmbyRemoteLine[]>(() => defaultEmbyRemoteLines(existing?.emby_lines))
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [token, setToken] = useState('') const [token, setToken] = useState('')
const [enabled, setEnabled] = useState(existing?.enabled ?? true) const [enabled, setEnabled] = useState(existing?.enabled ?? true)
const [saving, setSaving] = useState(false) 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<EmbyRemoteLine>) => {
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) => { const submit = async (event: React.FormEvent) => {
event.preventDefault() event.preventDefault()
const readyLines = normalizeEmbyRemoteLines(lines)
if (readyLines.length === 0) {
toast.error('请至少填写一条有效的 Emby 线路地址')
return
}
setSaving(true) setSaving(true)
try { try {
const config: Record<string, string> = { const config: Record<string, string> = {
...(url ? { url } : {}), ...encodeEmbyRemoteConfigLines(readyLines),
...(username ? { username } : {}), ...(username ? { username } : {}),
...(password ? { password } : {}), ...(password ? { password } : {}),
...(token ? { token } : {}), ...(token ? { token } : {}),
@@ -69,7 +104,7 @@ function AccountDialog({
toast.success('Emby 账号已更新') toast.success('Emby 账号已更新')
} else { } else {
if (!canSave()) { if (!canSave()) {
toast.error('请填写 Emby 地址与密码(或 API Key)') toast.error('请填写线路地址与密码(或 API Key)')
return return
} }
await strmAPI.createAccount({ name: name || 'Emby 服务器', provider: 'emby_remote', config }) await strmAPI.createAccount({ name: name || 'Emby 服务器', provider: 'emby_remote', config })
@@ -85,11 +120,11 @@ function AccountDialog({
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
<div className="w-full max-w-md rounded-3xl border border-sand-200 bg-white p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}> <div className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-3xl border border-sand-200 bg-white p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="mb-5 flex items-center justify-between"> <div className="mb-5 flex items-center justify-between">
<div> <div>
<h3 className="font-display text-xl font-bold text-ink-600">{existing ? '编辑 Emby 账号' : '添加 Emby 账号'}</h3> <h3 className="font-display text-xl font-bold text-ink-600">{existing ? '编辑 Emby 账号' : '添加 Emby 账号'}</h3>
<p className="text-xs text-sand-500">连接远程 Emby 服务器(Jellyfin 暂不支持)</p> <p className="text-xs text-sand-500">可配置内网、外网等多条线路,连接失败时按顺序自动切换</p>
</div> </div>
<button onClick={onClose} className="rounded-lg p-1.5 text-sand-500 hover:bg-gray-100"> <button onClick={onClose} className="rounded-lg p-1.5 text-sand-500 hover:bg-gray-100">
<X size={18} /> <X size={18} />
@@ -100,16 +135,48 @@ function AccountDialog({
<span className="mb-1 block font-semibold text-ink-600">账号名称</span> <span className="mb-1 block font-semibold text-ink-600">账号名称</span>
<input className={inputCls} value={name} placeholder="例如:家庭影院" onChange={(e) => setName(e.target.value)} /> <input className={inputCls} value={name} placeholder="例如:家庭影院" onChange={(e) => setName(e.target.value)} />
</label> </label>
<label className="block text-sm"> <div className="space-y-3">
<span className="mb-1 block font-semibold text-ink-600">Emby 服务地址</span> <div className="flex items-center justify-between gap-2">
<input <span className="text-sm font-semibold text-ink-600">接入线路</span>
className={inputCls} <button type="button" onClick={addLine} className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2.5 py-1 text-xs font-semibold text-ink-100 hover:bg-gray-50">
value={url} <Plus size={12} />
placeholder="http://192.168.1.10:8096" 添加线路
onChange={(e) => setUrl(e.target.value)} </button>
disabled={!!existing} </div>
/> {lines.map((line, index) => (
</label> <div key={`line-${index}`} className="space-y-2 rounded-2xl border border-sand-200 bg-gray-50/70 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-bold uppercase tracking-wide text-sand-500">
线路 {index + 1}
{existing?.emby_active_line === index ? ' · 当前优先' : index === 0 ? ' · 默认优先' : ''}
</span>
<div className="flex items-center gap-1">
<button type="button" onClick={() => moveLine(index, 'up')} disabled={index === 0} className="rounded-lg border border-gray-200 p-1 text-ink-100 hover:bg-white disabled:opacity-30">
<ArrowUp size={12} />
</button>
<button type="button" onClick={() => moveLine(index, 'down')} disabled={index === lines.length - 1} className="rounded-lg border border-gray-200 p-1 text-ink-100 hover:bg-white disabled:opacity-30">
<ArrowDown size={12} />
</button>
<button type="button" onClick={() => removeLine(index)} disabled={lines.length <= 1} className="rounded-lg border border-gray-200 p-1 text-red-500 hover:bg-red-50 disabled:opacity-30">
<Trash2 size={12} />
</button>
</div>
</div>
<input
className={inputCls}
value={line.name}
placeholder="线路名称,如:内网 / 外网 / IPv6"
onChange={(e) => updateLine(index, { name: e.target.value })}
/>
<input
className={inputCls}
value={line.url}
placeholder="http://192.168.1.10:8096 或 https://emby.example.com"
onChange={(e) => updateLine(index, { url: e.target.value })}
/>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="block text-sm"> <label className="block text-sm">
<span className="mb-1 block font-semibold text-ink-600">用户名</span> <span className="mb-1 block font-semibold text-ink-600">用户名</span>
@@ -478,7 +545,7 @@ export function EmbyMountPage() {
</span> </span>
</h1> </h1>
<p className="text-sm text-ink-50"> <p className="text-sm text-ink-50">
添加远程 Emby 服务器后,按需挂载其媒体库到本项目;播放默认直连原 Emby,可对单个媒体库开启本机代理 添加远程 Emby 服务器后,按需挂载其媒体库到本项目;可为同一服务器配置多条线路并自动切换
</p> </p>
</div> </div>
<button onClick={() => setAccountDialog('new')} className="neon-button"> <button onClick={() => setAccountDialog('new')} className="neon-button">
@@ -512,6 +579,9 @@ export function EmbyMountPage() {
<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}</p>
<p className="text-xs text-sand-500"> <p className="text-xs text-sand-500">
{acct.has_credential ? '凭据已配置' : '待补全凭据'} {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.last_test_result ? ` · 最近测试:${acct.last_test_ok ? '正常' : acct.last_test_result}` : ''}
</p> </p>
</div> </div>
+104 -6
View File
@@ -1,15 +1,19 @@
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react' import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { import {
ArrowDown,
ArrowUp,
ChevronRight, ChevronRight,
Cloud, Cloud,
ExternalLink, ExternalLink,
FolderPlus, FolderPlus,
HardDrive, HardDrive,
Loader2, Loader2,
Plus,
QrCode, QrCode,
RefreshCw, RefreshCw,
Search, Search,
Trash2,
Tv, Tv,
X, X,
} from 'lucide-react' } from 'lucide-react'
@@ -23,6 +27,12 @@ import type { StrmAccount, StrmSyncPath, StrmSyncPathInput, StrmProvider } from
import { STRM_PROVIDER_LABELS } from '../types/strm' import { STRM_PROVIDER_LABELS } from '../types/strm'
import { apiErrorMessage } from './StrmManagePage' import { apiErrorMessage } from './StrmManagePage'
import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog' 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 [name, setName] = useState(existing?.name ?? '')
const [enabled, setEnabled] = useState(existing?.enabled ?? true) const [enabled, setEnabled] = useState(existing?.enabled ?? true)
const [url, setUrl] = useState('') const [url, setUrl] = useState('')
const [embyLines, setEmbyLines] = useState<EmbyRemoteLine[]>(() =>
defaultEmbyRemoteLines(existing?.provider === 'emby_remote' ? existing?.emby_lines : undefined),
)
const [server, setServer] = useState('') const [server, setServer] = useState('')
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@@ -128,14 +141,16 @@ export function StrmAccountDialog({
...(password ? { password } : {}), ...(password ? { password } : {}),
...(token ? { token } : {}), ...(token ? { token } : {}),
} }
case 'emby_remote': case 'emby_remote': {
const lineConfig = encodeEmbyRemoteConfigLines(embyLines)
return { return {
...(url ? { url } : {}), ...lineConfig,
...(username ? { username } : {}), ...(username ? { username } : {}),
...(password ? { password } : {}), ...(password ? { password } : {}),
...(token ? { token } : {}), ...(token ? { token } : {}),
proxy_play: proxyPlay ? 'true' : 'false', proxy_play: proxyPlay ? 'true' : 'false',
} }
}
default: default:
return {} return {}
} }
@@ -148,7 +163,7 @@ export function StrmAccountDialog({
case 'openlist': case 'openlist':
return server !== '' && (token !== '' || password !== '') return server !== '' && (token !== '' || password !== '')
case 'emby_remote': case 'emby_remote':
return url !== '' && (token !== '' || password !== '') return normalizeEmbyRemoteLines(embyLines).length > 0 && (token !== '' || password !== '')
default: default:
return false return false
} }
@@ -255,9 +270,92 @@ export function StrmAccountDialog({
)} )}
{provider === 'emby_remote' && ( {provider === 'emby_remote' && (
<Field label="Emby 服务地址" hint="远程 Emby 服务器地址,如 http://192.168.1.10:8096"> <div className="space-y-3">
<input className={inputCls} value={url} placeholder="http://host:8096" onChange={(e) => setUrl(e.target.value)} /> <div className="flex items-start justify-between gap-2">
</Field> <div>
<p className="text-sm font-medium text-ink-100">接入线路</p>
<p className="text-xs text-sand-500">可配置内网、外网等多条线路,连接失败时按顺序自动切换</p>
</div>
<button
type="button"
onClick={() => setEmbyLines((prev) => [...prev, { name: `线路 ${prev.length + 1}`, url: '' }])}
className="inline-flex shrink-0 items-center gap-1 rounded-lg border border-gray-200 px-2.5 py-1 text-xs font-semibold text-ink-100 hover:bg-gray-50"
>
<Plus size={12} />
添加线路
</button>
</div>
{embyLines.map((line, index) => (
<div key={`emby-line-${index}`} className="space-y-2 rounded-2xl border border-sand-200 bg-gray-50/70 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-bold uppercase tracking-wide text-sand-500">
线路 {index + 1}
{existing?.emby_active_line === index ? ' · 当前优先' : index === 0 ? ' · 默认优先' : ''}
</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => {
setEmbyLines((prev) => {
const target = index - 1
if (target < 0) return prev
const next = [...prev]
const [moved] = next.splice(index, 1)
next.splice(target, 0, moved)
return next
})
}}
disabled={index === 0}
className="rounded-lg border border-gray-200 p-1 text-ink-100 hover:bg-white disabled:opacity-30"
>
<ArrowUp size={12} />
</button>
<button
type="button"
onClick={() => {
setEmbyLines((prev) => {
const target = index + 1
if (target >= prev.length) return prev
const next = [...prev]
const [moved] = next.splice(index, 1)
next.splice(target, 0, moved)
return next
})
}}
disabled={index === embyLines.length - 1}
className="rounded-lg border border-gray-200 p-1 text-ink-100 hover:bg-white disabled:opacity-30"
>
<ArrowDown size={12} />
</button>
<button
type="button"
onClick={() => setEmbyLines((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index)))}
disabled={embyLines.length <= 1}
className="rounded-lg border border-gray-200 p-1 text-red-500 hover:bg-red-50 disabled:opacity-30"
>
<Trash2 size={12} />
</button>
</div>
</div>
<input
className={inputCls}
value={line.name}
placeholder="线路名称,如:内网 / 外网 / IPv6"
onChange={(e) =>
setEmbyLines((prev) => prev.map((item, i) => (i === index ? { ...item, name: e.target.value } : item)))
}
/>
<input
className={inputCls}
value={line.url}
placeholder="http://192.168.1.10:8096 或 https://emby.example.com"
onChange={(e) =>
setEmbyLines((prev) => prev.map((item, i) => (i === index ? { ...item, url: e.target.value } : item)))
}
/>
</div>
))}
</div>
)} )}
{(provider === 'clouddrive2' || provider === 'emby_remote') && ( {(provider === 'clouddrive2' || provider === 'emby_remote') && (
+9
View File
@@ -9,6 +9,11 @@ export const STRM_PROVIDER_LABELS: Record<StrmProvider, string> = {
emby_remote: 'Emby 远程挂载', emby_remote: 'Emby 远程挂载',
} }
export type EmbyRemoteLine = {
name: string
url: string
}
export interface StrmAccount { export interface StrmAccount {
id: string id: string
name: string name: string
@@ -23,6 +28,10 @@ export interface StrmAccount {
provider_label: string provider_label: string
/** 仅远程 Emby 挂载账号返回:播放流量是否经过 MMTL 代理 */ /** 仅远程 Emby 挂载账号返回:播放流量是否经过 MMTL 代理 */
proxy_play?: boolean proxy_play?: boolean
/** 仅远程 Emby 挂载账号返回:多线路配置 */
emby_lines?: EmbyRemoteLine[]
/** 当前优先使用的线路下标 */
emby_active_line?: number
} }
export interface StrmAccountInput { export interface StrmAccountInput {
+31
View File
@@ -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<string, string> {
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: '' }]
}