mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-03 20:26:36 +08:00
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:
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+159
-14
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user