mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
feat: Emby 远程挂载(联邦聚合)功能 beta
- 新增 emby_remote 提供方:通过 STRM 账号体系配置远程 Emby 地址/用户名/密码
- EmbyService 聚合远程媒体库 Views/Items/详情/最近添加/全局搜索,远程数据不落库
- 条目 ID 统一伪装为 embyremote~{account}~{id},请求按账号路由回远程
- 播放支持账号级 proxy_play 配置:默认客户端直连远程;开启后由 MMTL 反向代理流/字幕
- 图片/元数据全部实时透传远程,播放状态(已看/收藏)透传回远程 Emby
- cloud 包新增 emby Provider(Ping/List/Resolve),账号测试/目录浏览自动生效
- 前端 STRM 页面支持 Emby 远程挂载账号(地址+凭据+代理开关)
This commit is contained in:
@@ -34,10 +34,19 @@ func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// embySubtitleStreamHandler serves an external subtitle track advertised in a
|
||||
// MediaSource's MediaStreams via its Emby index
|
||||
// (/Videos/:id/Subtitles/:index/Stream). The index maps to a discovered
|
||||
// sideloaded subtitle file next to the video (SRT/ASS/SSA/VTT, local or
|
||||
// cloud://), following the same layout appended by mediaStreams.
|
||||
// sideloaded subtitle track next to the video (SRT/ASS/SSA/VTT, local or
|
||||
// cloud://), following the same layout appended by mediaStreams. 远程 Emby
|
||||
// 条目的字幕直接反向代理远程。
|
||||
func embySubtitleStreamHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
encodedID := c.Param("id")
|
||||
if accountID, remoteID, ok := service.DecodeEmbyRemoteID(encodedID); ok {
|
||||
if err := svc.Emby.ProxyRemoteSubtitle(c.Request.Context(), c.Writer, c.Request, accountID, remoteID, c.Param("index")); err != nil {
|
||||
embyError(c, http.StatusNotFound, "subtitle not found")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
uid := c.Param("userId")
|
||||
if uid == "" {
|
||||
uid = embyUserID(c)
|
||||
@@ -213,12 +222,26 @@ func embyAppendAPIKey(raw, token string) string {
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口,
|
||||
// 直接代理到我们的 /api/stream/{id}(同一个 ServeFile)。
|
||||
// embyVideoStreamHandler 是 GET /Videos/{id}/stream 的入口。
|
||||
// 远程 Emby 条目(embyremote~ 前缀)走反向代理;本地条目直接代理到
|
||||
// /api/stream/{id}(同一个 ServeFile)。
|
||||
func embyVideoStreamHandler(svc *service.Container, cloudMode string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
encodedID := c.Param("id")
|
||||
if accountID, remoteID, ok := service.DecodeEmbyRemoteID(encodedID); ok {
|
||||
if err := svc.Emby.ProxyRemoteVideoStream(c.Request.Context(), c.Writer, c.Request, accountID, remoteID); err != nil {
|
||||
if errors.Is(err, service.ErrEmbyRemoteNotFound) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if !c.Writer.Written() {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
uid := embyUserID(c)
|
||||
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
|
||||
item, err := svc.Emby.Item(c.Request.Context(), encodedID, uid)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -296,6 +319,11 @@ func embyShouldRedirectVideoStreamToSTRM(c *gin.Context, svc *service.Container,
|
||||
|
||||
func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 远程 Emby 条目不做本地转码(播放地址已由 PlaybackInfo 指向远程/代理直连)。
|
||||
if service.IsEmbyRemoteID(c.Param("id")) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
uid := embyUserID(c)
|
||||
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil || item == nil || svc.Stream == nil {
|
||||
@@ -319,6 +347,10 @@ func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
|
||||
func embyVideoHLSSegmentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if service.IsEmbyRemoteID(c.Param("id")) {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
uid := embyUserID(c)
|
||||
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
|
||||
if err != nil || item == nil || svc.Stream == nil {
|
||||
|
||||
@@ -13,6 +13,7 @@ const (
|
||||
StrmProviderCloudDrive = "clouddrive2" // CloudDrive2(WebDAV 桥接)
|
||||
StrmProviderOpenList = "openlist" // OpenList / AList 兼容桥接
|
||||
StrmProviderLocal = "local" // 本地目录(无账号)
|
||||
StrmProviderEmbyRemote = "emby_remote" // 远程 Emby 服务器(API 网关聚合挂载,不走 STRM 同步)
|
||||
)
|
||||
|
||||
// StrmAccount 是一个网盘账号(STRM 同步数据源凭据)。
|
||||
|
||||
@@ -30,6 +30,7 @@ const (
|
||||
Type115 = "cloud115" // 115 网盘
|
||||
TypeCloudDrive2 = "clouddrive2" // CloudDrive2 桥接网盘
|
||||
TypeOpenList = "openlist" // OpenList / AList-compatible bridge
|
||||
TypeEmbyRemote = "emby_remote" // 远程 Emby 服务器(API 网关挂载)
|
||||
)
|
||||
|
||||
// ErrUnsupported is returned for an unknown provider type.
|
||||
@@ -101,6 +102,8 @@ func New(typ string, cfg map[string]any, client *http.Client) (Provider, error)
|
||||
return newCloudDrive2(cfg, client), nil
|
||||
case TypeOpenList:
|
||||
return newOpenList(cfg, client), nil
|
||||
case TypeEmbyRemote:
|
||||
return newEmby(cfg, client), nil
|
||||
default:
|
||||
return nil, ErrUnsupported
|
||||
}
|
||||
@@ -108,7 +111,7 @@ func New(typ string, cfg map[string]any, client *http.Client) (Provider, error)
|
||||
|
||||
// IsCloudType reports whether typ is a cloud-disk provider.
|
||||
func IsCloudType(typ string) bool {
|
||||
return typ == Type115 || typ == TypeCloudDrive2 || typ == TypeOpenList
|
||||
return typ == Type115 || typ == TypeCloudDrive2 || typ == TypeOpenList || typ == TypeEmbyRemote
|
||||
}
|
||||
|
||||
// str coerces a config value to a trimmed string.
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// Emby remote provider: exposes a remote Emby server through the same
|
||||
// Provider interface used by cloud disks, so account CRUD / connectivity
|
||||
// test / directory browser work unchanged. This is a thin adapter — the
|
||||
// federated Emby API aggregation (Views / Items / PlaybackInfo / streaming
|
||||
// proxy) lives in service.EmbyRemoteService and does not go through the
|
||||
// cloud-disk sync machinery.
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Emby 远程挂载类型(service 层聚合走 EmbyRemoteService,不走 STRM 同步)。
|
||||
|
||||
// embyProvider implements Provider against a remote Emby server using an
|
||||
// api_key (token) for authentication. DirectLink.Resolve returns the remote
|
||||
// stream URL; whether MMTL reverse-proxies the bytes is decided by the
|
||||
// emby.proxy_play account config (defaults to off).
|
||||
type embyProvider struct {
|
||||
base string // e.g. http://host:8096(自动补 /emby 前缀)
|
||||
username string
|
||||
password string
|
||||
token string // api_key
|
||||
userID string // 远程用户 Id
|
||||
proxyPlay bool
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type embyUserPayload struct {
|
||||
Id string `json:"Id"`
|
||||
}
|
||||
|
||||
type embyLoginResponse struct {
|
||||
AccessToken string `json:"AccessToken"`
|
||||
User embyUserPayload `json:"User"`
|
||||
}
|
||||
|
||||
type embyPingResponse struct {
|
||||
ServerName string `json:"ServerName"`
|
||||
}
|
||||
|
||||
// newEmby builds the provider from the account config map.
|
||||
func newEmby(cfg map[string]any, client *http.Client) Provider {
|
||||
p := &embyProvider{
|
||||
base: strings.TrimRight(str(cfg["url"]), "/"),
|
||||
username: str(cfg["username"]),
|
||||
password: str(cfg["password"]),
|
||||
token: firstNonEmpty(str(cfg["api_key"]), str(cfg["token"])),
|
||||
userID: str(cfg["remote_user_id"]),
|
||||
proxyPlay: boolish(cfg["proxy_play"]),
|
||||
client: client,
|
||||
}
|
||||
if p.client == nil {
|
||||
p.client = http.DefaultClient
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// embyBase normalizes the address so requests go to /emby/... endpoints.
|
||||
func (p *embyProvider) embyBase() string {
|
||||
base := strings.TrimRight(p.base, "/")
|
||||
if !strings.Contains(base, "/emby") {
|
||||
base += "/emby"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// externalBase 不追加 /emby(内嵌媒体资源 URL 使用 /emby 会更贴近习惯,此处
|
||||
// 与 embyBase 保持一致:所有端点统一以 /emby 开头)。
|
||||
func (p *embyProvider) apiBase() string { return p.embyBase() }
|
||||
|
||||
func (p *embyProvider) Type() string { return TypeEmbyRemote }
|
||||
|
||||
// Ping 验证地址连通性与凭据(/System/Info)。
|
||||
func (p *embyProvider) Ping(ctx context.Context) error {
|
||||
if p.base == "" {
|
||||
return errors.New("缺少 Emby 地址")
|
||||
}
|
||||
token, err := p.ensureToken(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.doJSON(ctx, http.MethodGet, "/System/Info", nil, token, &embyPingResponse{})
|
||||
}
|
||||
|
||||
// doJSON 向远程 Emby 发起带 api_key 的请求并解析 JSON 响应。
|
||||
func (p *embyProvider) doJSON(ctx context.Context, method, path string, body io.Reader, token string, out any) error {
|
||||
endpoint := p.apiBase() + path
|
||||
if token != "" {
|
||||
sep := "?"
|
||||
if strings.Contains(endpoint, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
endpoint += sep + "api_key=" + url.QueryEscape(token)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Emby-Token", token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return ErrEmbyUnauthorized
|
||||
}
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("emby 请求失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// ErrEmbyUnauthorized 表示远程凭据失效(触发重新认证/打回测试)。
|
||||
var ErrEmbyUnauthorized = errors.New("emby 认证失败或凭据已失效")
|
||||
|
||||
// ensureToken 返回可用 api_key:已有则直接用,否则尝试账号密码认证。
|
||||
func (p *embyProvider) ensureToken(ctx context.Context) (string, error) {
|
||||
if strings.TrimSpace(p.token) != "" {
|
||||
return p.token, nil
|
||||
}
|
||||
if strings.TrimSpace(p.username) == "" {
|
||||
return "", errors.New("缺少 Emby 凭据(token 或 用户名/密码)")
|
||||
}
|
||||
payload := map[string]string{"Username": p.username, "Pw": p.password}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.apiBase()+"/Users/AuthenticateByName", strings.NewReader(string(data)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Emby-Authorization", `MediaBrowser Client="MMTL", Device="MMTL-Federated", DeviceId="mmtl-federated", Version="1.0"`)
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("emby 登录失败(%d)", resp.StatusCode)
|
||||
}
|
||||
var login embyLoginResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&login); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(login.AccessToken) == "" {
|
||||
return "", errors.New("emby 登录成功但未返回 AccessToken")
|
||||
}
|
||||
p.token = login.AccessToken
|
||||
if login.User.Id != "" {
|
||||
p.userID = login.User.Id
|
||||
}
|
||||
return p.token, nil
|
||||
}
|
||||
|
||||
// embyItemSummary 目录浏览所需的最小 Emby 条目字段。
|
||||
type embyItemSummary struct {
|
||||
Id string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
IsFolder bool `json:"IsFolder"`
|
||||
ChildCount int `json:"ChildCount"`
|
||||
RunTimeTicks int64 `json:"RunTimeTicks"`
|
||||
}
|
||||
|
||||
type embyItemListResponse struct {
|
||||
Items []embyItemSummary `json:"Items"`
|
||||
}
|
||||
|
||||
// List 把远程媒体库(View)展开为目录树:dirID 为空=媒体库列表;否则返回该
|
||||
// 目录(Movie/Series/Season/Folder)下的条目。用于账号「浏览目录」调试入口。
|
||||
func (p *embyProvider) List(ctx context.Context, dirID string) ([]FileEntry, error) {
|
||||
token, err := p.ensureToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userID := p.userID
|
||||
if userID == "" {
|
||||
userID = "0" // 某些 Emby 允许用 0 代表管理员
|
||||
}
|
||||
path := "/Users/" + url.PathEscape(userID) + "/Items"
|
||||
if dirID != "" {
|
||||
path += "?ParentId=" + url.QueryEscape(dirID)
|
||||
} else {
|
||||
path += "?IncludeItemTypes=CollectionFolder"
|
||||
}
|
||||
var out embyItemListResponse
|
||||
if err := p.doJSON(ctx, http.MethodGet, path, nil, token, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]FileEntry, 0, len(out.Items))
|
||||
for _, it := range out.Items {
|
||||
size := int64(0)
|
||||
if it.RunTimeTicks > 0 {
|
||||
size = it.RunTimeTicks / 10_000_000 // 秒
|
||||
}
|
||||
entries = append(entries, FileEntry{
|
||||
ID: it.Id,
|
||||
Name: it.Name,
|
||||
IsDir: it.IsFolder || it.Type != "Movie",
|
||||
Size: size,
|
||||
})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// Resolve 返回远程 Emby 直链。Proxy=true 时由调用方(StrmService.ProxyDirect)
|
||||
// 反向代理流量;false 时 302 到直链。默认不代理(播放字节不经过 MMTL)。
|
||||
func (p *embyProvider) Resolve(ctx context.Context, fileRef string) (*DirectLink, error) {
|
||||
token, err := p.ensureToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u := p.apiBase() + "/Videos/" + url.PathEscape(fileRef) + "/stream"
|
||||
u += "?api_key=" + url.QueryEscape(token) + "&Static=true&MediaSourceId=" + url.QueryEscape(fileRef)
|
||||
return &DirectLink{URL: u, Headers: map[string]string{"X-Emby-Token": token}, Proxy: p.proxyPlay}, nil
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package cloud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeEmbyServer 记录请求,按路径返回远程 Emby 风格响应。
|
||||
func fakeEmbyServer(t *testing.T) *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/emby/Users/AuthenticateByName":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"AccessToken":"remote-token","User":{"Id":"user-9"}}`))
|
||||
case r.URL.Path == "/emby/System/Info":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ServerName":"RemoteEmby"}`))
|
||||
case r.URL.Path == "/emby/Users/user-9/Items" && r.URL.Query().Get("ParentId") == "":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"Items":[{"Id":"view-1","Name":"Movies","Type":"CollectionFolder","IsFolder":true}]}`))
|
||||
case r.URL.Path == "/emby/Users/user-9/Items":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"Items":[{"Id":"movie-1","Name":"Avatar","Type":"Movie","IsFolder":false}]}`))
|
||||
case strings.Contains(r.URL.Path, "/emby/Videos/movie-1/stream"):
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
_, _ = w.Write([]byte("fake-video-bytes"))
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestEmbyProviderPingAuthenticatesAndGetsToken(t *testing.T) {
|
||||
srv := fakeEmbyServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeEmbyRemote, map[string]any{
|
||||
"url": srv.URL,
|
||||
"username": "alice",
|
||||
"password": "secret",
|
||||
}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.Ping(context.Background()); err != nil {
|
||||
t.Fatalf("ping: %v", err)
|
||||
}
|
||||
// 认证成功后 token 被记住,第二次 Ping 不应再走登录。
|
||||
if err := p.Ping(context.Background()); err != nil {
|
||||
t.Fatalf("ping 2: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyProviderListViewsAndChildren(t *testing.T) {
|
||||
srv := fakeEmbyServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeEmbyRemote, map[string]any{
|
||||
"url": srv.URL,
|
||||
"api_key": "fixed-token",
|
||||
"remote_user_id": "user-9",
|
||||
}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, err := p.List(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("list root: %v", err)
|
||||
}
|
||||
if len(root) != 1 || root[0].Name != "Movies" || !root[0].IsDir {
|
||||
t.Fatalf("root listing = %+v", root)
|
||||
}
|
||||
children, err := p.List(context.Background(), "view-1")
|
||||
if err != nil {
|
||||
t.Fatalf("list children: %v", err)
|
||||
}
|
||||
if len(children) != 1 || children[0].Name != "Avatar" || children[0].ID != "movie-1" {
|
||||
t.Fatalf("children = %+v", children)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyProviderResolveDirectURLByDefault(t *testing.T) {
|
||||
srv := fakeEmbyServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeEmbyRemote, map[string]any{
|
||||
"url": srv.URL,
|
||||
"api_key": "fixed-token",
|
||||
"remote_user_id": "user-9",
|
||||
}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link, err := p.Resolve(context.Background(), "movie-1")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if !strings.Contains(link.URL, "/emby/Videos/movie-1/stream") {
|
||||
t.Fatalf("url = %q", link.URL)
|
||||
}
|
||||
if !strings.Contains(link.URL, "api_key=fixed-token") {
|
||||
t.Fatalf("url missing api_key: %q", link.URL)
|
||||
}
|
||||
// 默认不代理播放流量。
|
||||
if link.Proxy {
|
||||
t.Fatal("emby remote must not proxy by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyProviderResolveProxyWhenConfigured(t *testing.T) {
|
||||
srv := fakeEmbyServer(t)
|
||||
defer srv.Close()
|
||||
|
||||
p, err := New(TypeEmbyRemote, map[string]any{
|
||||
"url": srv.URL,
|
||||
"api_key": "fixed-token",
|
||||
"remote_user_id": "user-9",
|
||||
"proxy_play": "true",
|
||||
}, srv.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link, err := p.Resolve(context.Background(), "movie-1")
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if !link.Proxy {
|
||||
t.Fatal("proxy_play=true must mark link as proxied")
|
||||
}
|
||||
if link.URL == "" {
|
||||
t.Fatal("proxy link must still carry the remote URL")
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,15 @@ import (
|
||||
|
||||
// ImageURL returns artwork for a media/series/season item id.
|
||||
func (e *EmbyService) ImageURL(ctx context.Context, id, imageType string) (string, error) {
|
||||
// 远程 Emby 条目:直接返回远程图片绝对地址,由 ImageProxy 拉取透传。
|
||||
if e.remote != nil && IsEmbyRemoteID(id) {
|
||||
acctID, remoteID, _ := DecodeEmbyRemoteID(id)
|
||||
acct := e.remote.AccountByID(ctx, acctID)
|
||||
if acct == nil {
|
||||
return "", nil
|
||||
}
|
||||
return e.remote.RemoteImageURL(ctx, acct, remoteID, imageType)
|
||||
}
|
||||
pick := func(primary, backdrop string) string {
|
||||
switch strings.ToLower(imageType) {
|
||||
case "backdrop", "art":
|
||||
|
||||
@@ -48,6 +48,7 @@ type EmbyService struct {
|
||||
repo *repository.Container
|
||||
cache *RuntimeCacheService
|
||||
subtitle *SubtitleService
|
||||
remote *EmbyRemoteService // 远程 Emby 联邦聚合(可为 nil:未启用)
|
||||
|
||||
virtualMu sync.RWMutex
|
||||
virtualSeries map[string]embySeriesCacheEntry
|
||||
@@ -66,6 +67,14 @@ func NewEmbyService(cfg *config.Config, log *zap.Logger, repo *repository.Contai
|
||||
return &EmbyService{cfg: cfg, log: log, repo: repo}
|
||||
}
|
||||
|
||||
// SetEmbyRemote 注入远程 Emby 联邦聚合服务(nil 表示未启用)。
|
||||
func (e *EmbyService) SetEmbyRemote(remote *EmbyRemoteService) *EmbyService {
|
||||
if e != nil {
|
||||
e.remote = remote
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *EmbyService) SetRuntimeCache(cache *RuntimeCacheService) *EmbyService {
|
||||
if e != nil {
|
||||
e.cache = cache
|
||||
@@ -123,7 +132,8 @@ type embyVisibilityCacheEntry struct {
|
||||
|
||||
// Items paginates media in Emby's hierarchy. Episodic libraries are exposed as
|
||||
// Series -> Season -> Episode so Infuse/Vidhub/SenPlayer stop treating every
|
||||
// episode as a separate movie card.
|
||||
// episode as a separate movie card. 带 embyremote~ 前缀的 ParentID / 搜索自动
|
||||
// 路由到远程 Emby(联邦聚合,远程数据不落库)。
|
||||
func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, error) {
|
||||
if p.Limit <= 0 || p.Limit > 500 {
|
||||
p.Limit = 50
|
||||
@@ -135,6 +145,22 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
||||
return emptyItemsEnvelope(p.StartIndex), nil
|
||||
}
|
||||
|
||||
if e.remote != nil {
|
||||
// 远程目录浏览:ParentId 带远程前缀 → 完整转发给远程 Emby 承接分页。
|
||||
if IsEmbyRemoteID(p.ParentID) {
|
||||
acctID, _, _ := DecodeEmbyRemoteID(p.ParentID)
|
||||
acct := e.remote.AccountByID(ctx, acctID)
|
||||
if acct == nil {
|
||||
return emptyItemsEnvelope(p.StartIndex), nil
|
||||
}
|
||||
return e.remote.RemoteItems(ctx, acct, p)
|
||||
}
|
||||
// 全局搜索:无 ParentId 且带搜索词 → 聚合本地 + 全部远程。
|
||||
if p.ParentID == "" && p.SearchTerm != "" {
|
||||
return e.aggregatedSearch(ctx, p)
|
||||
}
|
||||
}
|
||||
|
||||
if len(p.IDs) > 0 {
|
||||
items := make([]map[string]any, 0, len(p.IDs))
|
||||
for _, id := range p.IDs {
|
||||
@@ -212,3 +238,74 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
||||
}
|
||||
return e.mediaItems(ctx, p)
|
||||
}
|
||||
|
||||
// aggregatedSearch 把本地媒体库与全部启用的远程 Emby 的搜索结果合并为一个
|
||||
// 分页载荷。本地结果保持原有分页语义,远程各自取一页(Limit 同款)后按
|
||||
// SortBy 做稳定排序切片。
|
||||
func (e *EmbyService) aggregatedSearch(ctx context.Context, p ItemsParams) (map[string]any, error) {
|
||||
local, err := e.mediaItems(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type remoteResult struct {
|
||||
items []any
|
||||
}
|
||||
accounts, aerr := e.remote.ListAccounts(ctx)
|
||||
results := make([]remoteResult, 0, len(accounts))
|
||||
if aerr == nil {
|
||||
for i := range accounts {
|
||||
acct := &accounts[i]
|
||||
remote, rerr := e.remote.RemoteItems(ctx, acct, p)
|
||||
if rerr != nil {
|
||||
if e.log != nil {
|
||||
e.log.Warn("remote emby search failed",
|
||||
zap.String("account", acct.Name), zap.Error(rerr))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if raw, ok := remote["Items"].([]any); ok {
|
||||
results = append(results, remoteResult{items: raw})
|
||||
} else if rawMap, ok := remote["Items"].([]map[string]any); ok {
|
||||
converted := make([]any, 0, len(rawMap))
|
||||
for _, m := range rawMap {
|
||||
converted = append(converted, any(m))
|
||||
}
|
||||
results = append(results, remoteResult{items: converted})
|
||||
}
|
||||
}
|
||||
}
|
||||
items := make([]any, 0, len(localItemsAsAny(local))+len(results)*p.Limit)
|
||||
items = append(items, localItemsAsAny(local)...)
|
||||
for _, res := range results {
|
||||
items = append(items, res.items...)
|
||||
}
|
||||
return sliceSearchItems(items, p), nil
|
||||
}
|
||||
|
||||
func localItemsAsAny(envelope map[string]any) []any {
|
||||
if envelope == nil {
|
||||
return nil
|
||||
}
|
||||
if raw, ok := envelope["Items"].([]any); ok {
|
||||
return raw
|
||||
}
|
||||
if raw, ok := envelope["Items"].([]map[string]any); ok {
|
||||
converted := make([]any, 0, len(raw))
|
||||
for _, m := range raw {
|
||||
converted = append(converted, any(m))
|
||||
}
|
||||
return converted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sliceSearchItems 对合并结果按请求排序做简单归类后分页。远程返回已按远程
|
||||
// 排序规则排好,这里保持稳定顺序,只做首/尾切片,避免过度重排造成分页跳动。
|
||||
func sliceSearchItems(items []any, p ItemsParams) map[string]any {
|
||||
total := len(items)
|
||||
if p.StartIndex >= total {
|
||||
return map[string]any{"Items": []any{}, "TotalRecordCount": total, "StartIndex": p.StartIndex}
|
||||
}
|
||||
end := minInt(p.StartIndex+p.Limit, total)
|
||||
return map[string]any{"Items": items[p.StartIndex:end], "TotalRecordCount": total, "StartIndex": p.StartIndex}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,15 @@ import (
|
||||
|
||||
// Item 单条目详情。
|
||||
func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[string]any, error) {
|
||||
// 远程 Emby 条目:不查本地库,直接向远程转发(保持远程最新元数据)。
|
||||
if e.remote != nil && IsEmbyRemoteID(mediaID) {
|
||||
acctID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||
acct := e.remote.AccountByID(ctx, acctID)
|
||||
if acct == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return e.remote.RemoteItem(ctx, acct, remoteID)
|
||||
}
|
||||
if lib, err := e.repo.Library.FindByID(ctx, mediaID); err != nil {
|
||||
return nil, err
|
||||
} else if lib != nil {
|
||||
@@ -71,11 +80,19 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str
|
||||
return e.itemPayload(ctx, m, fav, pos), nil
|
||||
}
|
||||
|
||||
// LatestItems 最近添加,全库或指定库。
|
||||
// LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)直接透传远程。
|
||||
func (e *EmbyService) LatestItems(ctx context.Context, userID, parentID string, limit int) ([]map[string]any, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if e.remote != nil && IsEmbyRemoteID(parentID) {
|
||||
acctID, remoteParent, _ := DecodeEmbyRemoteID(parentID)
|
||||
acct := e.remote.AccountByID(ctx, acctID)
|
||||
if acct == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return e.remote.RemoteLatest(ctx, acct, remoteParent, limit)
|
||||
}
|
||||
cacheKey := e.embyLatestCacheKey(userID, parentID, limit)
|
||||
var cached embyLatestCacheValue
|
||||
if e.cache != nil && e.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -14,7 +15,25 @@ import (
|
||||
)
|
||||
|
||||
// PlaybackInfo returns a PlaybackInfoResponse usable by Emby clients.
|
||||
// 远程 Emby 条目直接转发远程 PlaybackInfo,并按账号 proxy_play 配置决定
|
||||
// 播放地址指向远程(直连)还是 MMTL 本地代理端点。
|
||||
func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string) (map[string]any, error) {
|
||||
if e.remote != nil && IsEmbyRemoteID(mediaID) {
|
||||
acctID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||
acct := e.remote.AccountByID(ctx, acctID)
|
||||
if acct == nil {
|
||||
return nil, ErrEmbyRemoteNotFound
|
||||
}
|
||||
out, err := e.remote.RemotePlaybackInfo(ctx, acct, remoteID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
return nil, ErrEmbyRemoteNotFound
|
||||
}
|
||||
out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", acctID, time.Now().Unix())
|
||||
return out, nil
|
||||
}
|
||||
m, err := e.playableMedia(ctx, mediaID, userID)
|
||||
if err != nil || m == nil {
|
||||
return nil, err
|
||||
@@ -25,6 +44,65 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ErrEmbyRemoteNotFound 表示伪装 ID 对应的远程挂载账号不存在/已禁用。
|
||||
var ErrEmbyRemoteNotFound = fmt.Errorf("remote emby account not found")
|
||||
|
||||
// RemoteAccountByID 供 handler 层解码伪装 ID 后获取远程账号。
|
||||
func (e *EmbyService) RemoteAccountByID(ctx context.Context, accountID string) *model.StrmAccount {
|
||||
if e == nil || e.remote == nil {
|
||||
return nil
|
||||
}
|
||||
return e.remote.AccountByID(ctx, accountID)
|
||||
}
|
||||
|
||||
// ProxyRemoteVideoStream 反向代理远程 Emby 视频流(保留 Range)。
|
||||
func (e *EmbyService) ProxyRemoteVideoStream(ctx context.Context, w http.ResponseWriter, r *http.Request, accountID, remoteID string) error {
|
||||
if e == nil || e.remote == nil {
|
||||
return ErrEmbyRemoteNotFound
|
||||
}
|
||||
acct := e.remote.AccountByID(ctx, accountID)
|
||||
if acct == nil {
|
||||
return ErrEmbyRemoteNotFound
|
||||
}
|
||||
return e.remote.ProxyVideoStream(ctx, w, r, acct, remoteID)
|
||||
}
|
||||
|
||||
// ProxyRemoteSubtitle 反向代理远程 Emby 字幕流。
|
||||
func (e *EmbyService) ProxyRemoteSubtitle(ctx context.Context, w http.ResponseWriter, r *http.Request, accountID, remoteID, index string) error {
|
||||
if e == nil || e.remote == nil {
|
||||
return ErrSubtitleNotFound
|
||||
}
|
||||
acct := e.remote.AccountByID(ctx, accountID)
|
||||
if acct == nil {
|
||||
return ErrSubtitleNotFound
|
||||
}
|
||||
return e.remote.ProxySubtitle(ctx, w, r, acct, remoteID, index)
|
||||
}
|
||||
|
||||
// ProxyRemoteSetPlayed 把已看/未看状态透传到远程 Emby。
|
||||
func (e *EmbyService) ProxyRemoteSetPlayed(ctx context.Context, accountID, remoteID string, played bool) error {
|
||||
if e == nil || e.remote == nil {
|
||||
return ErrEmbyRemoteNotFound
|
||||
}
|
||||
acct := e.remote.AccountByID(ctx, accountID)
|
||||
if acct == nil {
|
||||
return ErrEmbyRemoteNotFound
|
||||
}
|
||||
return e.remote.ProxySetPlayed(ctx, acct, remoteID, played)
|
||||
}
|
||||
|
||||
// ProxyRemoteSetFavorite 把收藏/取消收藏状态透传到远程 Emby。
|
||||
func (e *EmbyService) ProxyRemoteSetFavorite(ctx context.Context, accountID, remoteID string, favorite bool) error {
|
||||
if e == nil || e.remote == nil {
|
||||
return ErrEmbyRemoteNotFound
|
||||
}
|
||||
acct := e.remote.AccountByID(ctx, accountID)
|
||||
if acct == nil {
|
||||
return ErrEmbyRemoteNotFound
|
||||
}
|
||||
return e.remote.ProxySetFavorite(ctx, acct, remoteID, favorite)
|
||||
}
|
||||
|
||||
// ServeSubtitleStream resolves the Emby /Videos/:id/Subtitles/:index/Stream
|
||||
// request to one of the media's sideloaded external subtitle tracks and writes
|
||||
// the original (unconverted) subtitle bytes to w — matching the source Codec
|
||||
@@ -39,6 +117,11 @@ func (e *EmbyService) ServeSubtitleStream(ctx context.Context, w io.Writer, medi
|
||||
if e == nil || e.subtitle == nil {
|
||||
return ErrSubtitleUnavailable
|
||||
}
|
||||
if e.remote != nil && IsEmbyRemoteID(mediaID) {
|
||||
// 远程字幕由反向代理透传(需要 http.ResponseWriter 能力),handler 层
|
||||
// 已对远程 ID 走 ProxyRemoteSubtitle,这里不重复处理。
|
||||
return ErrSubtitleNotFound
|
||||
}
|
||||
m, err := e.playableMedia(ctx, mediaID, userID)
|
||||
if err != nil || m == nil {
|
||||
return ErrSubtitleNotFound
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
// EmbyRemoteService 是「远程 Emby 联邦聚合」核心:把挂载的远程 Emby 服务器
|
||||
// 作为外部媒体源,通过 MMTL 的 Emby 兼容 API 透出。
|
||||
//
|
||||
// 设计要点:
|
||||
// - 远程媒体的元数据完全不落库:每次请求实时向远程 Emby 拉取;
|
||||
// - 条目 ID 用 embyremote~{accountID}~{remoteID} 伪装(见 emby_remote_ids.go),
|
||||
// 客户端拿伪装 ID 回来时按账号路由回远程;
|
||||
// - 播放分流由账号级 proxy_play 配置决定:
|
||||
// 不代理(默认)= MediaSource 下发热门远程绝对 URL,播放字节完全不经过 MMTL;
|
||||
// 代理 = 下发 MMTL 本地 /Videos/{encoded} 端点,由 ProxyVideoStream 反向拉流。
|
||||
//
|
||||
// 配置复用 STRM 账号体系(StrmAccount.Provider = emby_remote),CRUD/加密/连通
|
||||
// 测试全部走既有 /admin/strm/accounts 接口,不需要新增数据表。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/config"
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
"github.com/ShukeBta/MMTL/internal/repository"
|
||||
)
|
||||
|
||||
// embyRemoteHTTPTimeout 远程 Emby 常规 API 请求超时(流式代理不在此列)。
|
||||
const embyRemoteHTTPTimeout = 15 * time.Second
|
||||
|
||||
// EmbyRemoteConfig 是一个远程 Emby 账号的解密配置。
|
||||
type EmbyRemoteConfig struct {
|
||||
BaseURL string // http://host:8096(无需 /emby 后缀)
|
||||
Username string
|
||||
Password string
|
||||
Token string // api_key(手动填写或自动认证获得)
|
||||
RemoteUserID string // 远程用户 Id(自动认证后回填)
|
||||
ProxyPlay bool // true=播放流量经 MMTL 反向代理;false=客户端直连远程
|
||||
}
|
||||
|
||||
// EmbyRemoteService 提供对远程 Emby 服务器的读写封装。
|
||||
type EmbyRemoteService struct {
|
||||
cfg *config.Config
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
crypto *CryptoService
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// NewEmbyRemoteService 构造远程 Emby 聚合服务。
|
||||
func NewEmbyRemoteService(cfg *config.Config, log *zap.Logger, repo *repository.Container, crypto *CryptoService) *EmbyRemoteService {
|
||||
return &EmbyRemoteService{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
repo: repo,
|
||||
crypto: crypto,
|
||||
http: &http.Client{
|
||||
Timeout: embyRemoteHTTPTimeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ListAccounts 返回全部启用的远程 Emby 挂载账号。
|
||||
func (r *EmbyRemoteService) ListAccounts(ctx context.Context) ([]model.StrmAccount, error) {
|
||||
accounts, err := r.repo.StrmAccount.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]model.StrmAccount, 0, len(accounts))
|
||||
for i := range accounts {
|
||||
if accounts[i].Provider == model.StrmProviderEmbyRemote {
|
||||
out = append(out, accounts[i])
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AccountByID 按 ID 查找远程 Emby 挂载账号(不存在或类型不符返回 nil)。
|
||||
func (r *EmbyRemoteService) AccountByID(ctx context.Context, id string) *model.StrmAccount {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return nil
|
||||
}
|
||||
acct, err := r.repo.StrmAccount.FindByID(ctx, id)
|
||||
if err != nil || acct == nil {
|
||||
return nil
|
||||
}
|
||||
if acct.Provider != model.StrmProviderEmbyRemote || !acct.Enabled {
|
||||
return nil
|
||||
}
|
||||
return acct
|
||||
}
|
||||
|
||||
// configOf 解密账号配置。
|
||||
func (r *EmbyRemoteService) configOf(acct *model.StrmAccount) (*EmbyRemoteConfig, error) {
|
||||
raw := map[string]string{}
|
||||
if acct != nil && strings.TrimSpace(acct.Config) != "" {
|
||||
if err := json.Unmarshal([]byte(acct.Config), &raw); err != nil {
|
||||
return nil, fmt.Errorf("decode emby account config: %w", err)
|
||||
}
|
||||
}
|
||||
cfg := &EmbyRemoteConfig{
|
||||
BaseURL: strings.TrimRight(strings.TrimSpace(raw["url"]), "/"),
|
||||
Username: strings.TrimSpace(raw["username"]),
|
||||
Password: r.crypto.Decrypt(raw["password"]),
|
||||
Token: firstNonEmptyStr(r.crypto.Decrypt(raw["api_key"]), r.crypto.Decrypt(raw["token"])),
|
||||
RemoteUserID: strings.TrimSpace(raw["remote_user_id"]),
|
||||
ProxyPlay: parseBoolSetting(raw["proxy_play"], false),
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func firstNonEmptyStr(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// embyBase 把地址规范为不带尾部斜杠的 /emby 根。
|
||||
func (r *EmbyRemoteService) embyBase(cfg *EmbyRemoteConfig) string {
|
||||
base := strings.TrimRight(cfg.BaseURL, "/")
|
||||
if !strings.HasSuffix(base, "/emby") {
|
||||
base += "/emby"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// ensureToken 返回可用的 api_key:已有则直接用;否则用用户名/密码认证并回写
|
||||
// 数据库(自动获得的 token 与 remote_user_id 会加密保存在账号配置里)。
|
||||
func (r *EmbyRemoteService) ensureToken(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig) error {
|
||||
if strings.TrimSpace(cfg.Token) != "" {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" {
|
||||
return errors.New("缺少 Emby 凭据:请填写 api_key 或 用户名/密码")
|
||||
}
|
||||
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)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Emby-Authorization", `MediaBrowser Client="MMTL", Device="MMTL-Federated", DeviceId="mmtl-federated", Version="1.0"`)
|
||||
resp, err := r.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接远程 Emby 失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("远程 Emby 登录失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
var login struct {
|
||||
AccessToken string `json:"AccessToken"`
|
||||
User struct {
|
||||
Id string `json:"Id"`
|
||||
} `json:"User"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&login); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(login.AccessToken) == "" {
|
||||
return errors.New("远程 Emby 未返回 AccessToken")
|
||||
}
|
||||
cfg.Token = login.AccessToken
|
||||
if login.User.Id != "" {
|
||||
cfg.RemoteUserID = login.User.Id
|
||||
}
|
||||
return r.persistToken(ctx, acct, cfg)
|
||||
}
|
||||
|
||||
// persistToken 把认证得到的 token / user id 加密写回账号配置(下次请求免登录)。
|
||||
func (r *EmbyRemoteService) persistToken(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig) error {
|
||||
if acct == nil {
|
||||
return nil
|
||||
}
|
||||
raw := map[string]string{}
|
||||
if strings.TrimSpace(acct.Config) != "" {
|
||||
_ = json.Unmarshal([]byte(acct.Config), &raw)
|
||||
}
|
||||
raw["api_key"] = r.crypto.Encrypt(cfg.Token)
|
||||
raw["remote_user_id"] = cfg.RemoteUserID
|
||||
if strings.TrimSpace(raw["username"]) == "" {
|
||||
raw["username"] = cfg.Username
|
||||
}
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acct.Config = string(data)
|
||||
return r.repo.StrmAccount.Update(ctx, acct)
|
||||
}
|
||||
|
||||
// doGet 向远程 Emby 发起带 api_key 的 GET,把响应 JSON 解码到 out。
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
endpoint := r.embyBase(cfg) + path
|
||||
if q != nil {
|
||||
endpoint += "?" + q.Encode()
|
||||
} else {
|
||||
endpoint += "?api_key=" + url.QueryEscape(cfg.Token)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Emby-Token", cfg.Token)
|
||||
resp, err := r.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("请求远程 Emby 失败: %w", err)
|
||||
}
|
||||
data, readErr := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
resp.Body.Close()
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized && attempt == 0 {
|
||||
// token 失效:清空后重认证重试一次。
|
||||
cfg.Token = ""
|
||||
if acct != nil {
|
||||
raw := map[string]string{}
|
||||
_ = json.Unmarshal([]byte(acct.Config), &raw)
|
||||
delete(raw, "api_key")
|
||||
enc, _ := json.Marshal(raw)
|
||||
acct.Config = string(enc)
|
||||
_ = r.repo.StrmAccount.Update(ctx, acct)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("远程 Emby 请求失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, out)
|
||||
}
|
||||
return errors.New("远程 Emby 认证重试失败")
|
||||
}
|
||||
|
||||
// TestConnection 连通性测试:确保地址可达且凭据有效;成功时回写自动认证信息。
|
||||
func (r *EmbyRemoteService) TestConnection(ctx context.Context, acct *model.StrmAccount) error {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureToken(ctx, acct, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
var out json.RawMessage
|
||||
return r.doGet(ctx, acct, cfg, "/System/Info", nil, &out)
|
||||
}
|
||||
|
||||
// ─── 元数据 / 目录聚合 ─────────────────────────────────────────────────────────
|
||||
|
||||
// RemoteViews 拉取远程媒体库(View)列表,返回远程原始 view map(未重写)。
|
||||
func (r *EmbyRemoteService) RemoteViews(ctx context.Context, acct *model.StrmAccount) ([]map[string]any, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := url.Values{"api_key": {cfg.Token}}
|
||||
var body struct {
|
||||
Items []map[string]any `json:"Items"`
|
||||
}
|
||||
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Views", q, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return body.Items, nil
|
||||
}
|
||||
|
||||
func (r *EmbyRemoteService) remoteUserID(cfg *EmbyRemoteConfig) string {
|
||||
if strings.TrimSpace(cfg.RemoteUserID) != "" {
|
||||
return cfg.RemoteUserID
|
||||
}
|
||||
return "0" // 未认证出的兜底:部分 Emby 接受 0 代表管理员
|
||||
}
|
||||
|
||||
// RemoteItems 向远程 Emby 转发 /Items 浏览/搜索请求,返回重写后的响应载荷。
|
||||
// p 的分页/排序/过滤参数原样转发,分页语义完全由远程承接。
|
||||
func (r *EmbyRemoteService) RemoteItems(ctx context.Context, acct *model.StrmAccount, p ItemsParams) (map[string]any, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, remoteParent, _ := DecodeEmbyRemoteID(p.ParentID)
|
||||
q := url.Values{}
|
||||
if remoteParent != "" {
|
||||
q.Set("ParentId", remoteParent)
|
||||
}
|
||||
q.Set("UserId", r.remoteUserID(cfg))
|
||||
q.Set("Limit", strconv.Itoa(p.Limit))
|
||||
q.Set("StartIndex", strconv.Itoa(p.StartIndex))
|
||||
if p.SearchTerm != "" {
|
||||
q.Set("SearchTerm", p.SearchTerm)
|
||||
}
|
||||
if p.Recursive {
|
||||
q.Set("Recursive", "true")
|
||||
}
|
||||
if p.SortBy != "" {
|
||||
q.Set("SortBy", p.SortBy)
|
||||
}
|
||||
if p.SortOrder != "" {
|
||||
q.Set("SortOrder", p.SortOrder)
|
||||
}
|
||||
if len(p.IncludeItemTypes) > 0 {
|
||||
q.Set("IncludeItemTypes", strings.Join(p.IncludeItemTypes, ","))
|
||||
}
|
||||
if len(p.Filters) > 0 {
|
||||
q.Set("Filters", strings.Join(p.Filters, ","))
|
||||
}
|
||||
path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items"
|
||||
var out map[string]any
|
||||
if err := r.doGet(ctx, acct, cfg, path, q, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = map[string]any{"Items": []any{}, "TotalRecordCount": 0, "StartIndex": p.StartIndex}
|
||||
}
|
||||
RewriteEmbyRemoteIDs(out, acct.ID)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RemoteItem 拉取远程单条目详情(含响应的重写)。
|
||||
func (r *EmbyRemoteService) RemoteItem(ctx context.Context, acct *model.StrmAccount, remoteID string) (map[string]any, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/" + url.PathEscape(remoteID)
|
||||
var out map[string]any
|
||||
if err := r.doGet(ctx, acct, cfg, path, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
RewriteEmbyRemoteIDs(out, acct.ID)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RemoteLatest 拉取远程「最近添加」(用于 /Items/Latest 聚合)。
|
||||
func (r *EmbyRemoteService) RemoteLatest(ctx context.Context, acct *model.StrmAccount, parentID string, limit int) ([]map[string]any, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := url.Values{"Limit": {strconv.Itoa(limit)}}
|
||||
if parentID != "" {
|
||||
q.Set("ParentId", parentID)
|
||||
}
|
||||
path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/Latest"
|
||||
var out []map[string]any
|
||||
if err := r.doGet(ctx, acct, cfg, path, q, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
RewriteEmbyRemoteIDs(out, acct.ID)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RemotePlaybackInfo 拉取远程 PlaybackInfo,并按 proxy_play 配置重写播放 URL:
|
||||
// - 不代理:MediaSource 的 DirectStreamUrl / TranscodingUrl / 字幕 DeliveryUrl
|
||||
// 指向远程绝对地址(播放字节不过 MMTL);
|
||||
// - 代理:指向 MMTL 本地 /Videos/{encodedID} 端点(由 ProxyVideoStream 反代)。
|
||||
func (r *EmbyRemoteService) RemotePlaybackInfo(ctx context.Context, acct *model.StrmAccount, remoteID, userID string) (map[string]any, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := url.Values{"UserId": {r.remoteUserID(cfg)}}
|
||||
path := "/Items/" + url.PathEscape(remoteID) + "/PlaybackInfo"
|
||||
var out map[string]any
|
||||
if err := r.doGet(ctx, acct, cfg, path, q, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
RewriteEmbyRemoteIDs(out, acct.ID)
|
||||
r.rewritePlayURLs(out, acct, cfg, remoteID)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// rewritePlayURLs 按代理模式重写载荷内 MediaSources 的播放地址。
|
||||
func (r *EmbyRemoteService) rewritePlayURLs(value any, acct *model.StrmAccount, cfg *EmbyRemoteConfig, remoteID string) {
|
||||
encoded := EncodeEmbyRemoteID(acct.ID, remoteID)
|
||||
sources := collectMediaSources(value)
|
||||
if sources == nil {
|
||||
return
|
||||
}
|
||||
for _, src := range sources {
|
||||
mediaSourceID, _ := src["Id"].(string)
|
||||
var streamPath, subtitlePlayURL string
|
||||
if cfg.ProxyPlay {
|
||||
streamPath = "/Videos/" + url.PathEscape(encoded) + "/stream"
|
||||
subtitlePlayURL = "/Videos/" + url.PathEscape(encoded)
|
||||
} else {
|
||||
base := r.embyBase(cfg)
|
||||
streamPath = base + "/Videos/" + url.PathEscape(remoteID) + "/stream?api_key=" + url.QueryEscape(cfg.Token) + "&Static=true"
|
||||
if mediaSourceID != "" {
|
||||
streamPath += "&MediaSourceId=" + url.QueryEscape(mediaSourceID)
|
||||
}
|
||||
subtitlePlayURL = base + "/Videos/" + url.PathEscape(remoteID)
|
||||
}
|
||||
if _, exists := src["DirectStreamUrl"]; exists {
|
||||
src["DirectStreamUrl"] = streamPath
|
||||
}
|
||||
if _, exists := src["TranscodingUrl"]; exists {
|
||||
src["TranscodingUrl"] = streamPath
|
||||
}
|
||||
rewriteSubtitleDeliveryURLs(src, subtitlePlayURL, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// collectMediaSources 从载荷中取出所有 MediaSources(顶层或嵌套 Items 内)。
|
||||
func collectMediaSources(value any) []map[string]any {
|
||||
var out []map[string]any
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
if sources, ok := typed["MediaSources"].([]map[string]any); ok {
|
||||
out = append(out, sources...)
|
||||
} else if sources, ok := typed["MediaSources"].([]any); ok {
|
||||
for _, s := range sources {
|
||||
if m, isMap := s.(map[string]any); isMap {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if items, ok := typed["Items"]; ok {
|
||||
out = append(out, collectMediaSources(items)...)
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
out = append(out, collectMediaSources(item)...)
|
||||
}
|
||||
case []map[string]any:
|
||||
for _, item := range typed {
|
||||
out = append(out, collectMediaSources(item)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var embySubtitleDeliveryRE = regexp.MustCompile(`/Subtitles/(\d+)/Stream(\.[A-Za-z0-9]+)?`)
|
||||
|
||||
// rewriteSubtitleDeliveryURLs 把 MediaSource 内字幕轨道的 DeliveryUrl 改写到
|
||||
// subtitlePlayURL 前缀(客户端请求本地代理端点 / 远程绝对地址)。
|
||||
func rewriteSubtitleDeliveryURLs(src map[string]any, playURL string, cfg *EmbyRemoteConfig) {
|
||||
if src == nil {
|
||||
return
|
||||
}
|
||||
streams, ok := src["MediaStreams"].([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, s := range streams {
|
||||
stream, isMap := s.(map[string]any)
|
||||
if !isMap || stream["Type"] != "Subtitle" {
|
||||
continue
|
||||
}
|
||||
raw, _ := stream["DeliveryUrl"].(string)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
idx := "1"
|
||||
if m := embySubtitleDeliveryRE.FindStringSubmatch(raw); len(m) >= 2 {
|
||||
idx = m[1]
|
||||
}
|
||||
ext := ""
|
||||
if m := embySubtitleDeliveryRE.FindStringSubmatch(raw); len(m) >= 3 {
|
||||
ext = m[2]
|
||||
}
|
||||
base := strings.TrimRight(playURL, "/")
|
||||
delivery := base + "/Subtitles/" + idx + "/Stream" + ext
|
||||
if !cfg.ProxyPlay && strings.TrimSpace(cfg.Token) != "" {
|
||||
delivery += "?api_key=" + url.QueryEscape(cfg.Token)
|
||||
}
|
||||
stream["DeliveryUrl"] = delivery
|
||||
}
|
||||
}
|
||||
|
||||
// RemoteImageURL 构造远程图片绝对地址(由既有 ImageProxy 拉取透传)。
|
||||
func (r *EmbyRemoteService) RemoteImageURL(ctx context.Context, acct *model.StrmAccount, remoteID, imageType string) (string, error) {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.embyBase(cfg) + "/Items/" + url.PathEscape(remoteID) + "/Images/" + url.PathEscape(strings.ToLower(imageType)) +
|
||||
"?api_key=" + url.QueryEscape(cfg.Token), nil
|
||||
}
|
||||
|
||||
// ─── 播放代理 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ProxyVideoStream 反向代理远程 Emby 视频流(保留 Range 以支持拖动)。
|
||||
func (r *EmbyRemoteService) ProxyVideoStream(ctx context.Context, w http.ResponseWriter, req *http.Request, acct *model.StrmAccount, remoteID string) error {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureToken(ctx, acct, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
endpoint := r.embyBase(cfg) + "/Videos/" + url.PathEscape(remoteID) + "/stream"
|
||||
q := url.Values{}
|
||||
if mediaSourceID := strings.TrimSpace(req.URL.Query().Get("MediaSourceId")); mediaSourceID != "" {
|
||||
q.Set("MediaSourceId", mediaSourceID)
|
||||
}
|
||||
if static := strings.TrimSpace(req.URL.Query().Get("Static")); static != "" {
|
||||
q.Set("Static", static)
|
||||
}
|
||||
q.Set("api_key", cfg.Token)
|
||||
if encoded := q.Encode(); encoded != "" {
|
||||
endpoint += "?" + encoded
|
||||
}
|
||||
upstream, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
upstream.Header.Set("X-Emby-Token", cfg.Token)
|
||||
if rangeHeader := req.Header.Get("Range"); rangeHeader != "" {
|
||||
upstream.Header.Set("Range", rangeHeader)
|
||||
}
|
||||
resp, err := r.http.Do(upstream)
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接远程 Emby 视频流失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
return fmt.Errorf("远程 Emby 视频流失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Cache-Control"} {
|
||||
if value := resp.Header.Get(header); value != "" {
|
||||
w.Header().Set(header, value)
|
||||
}
|
||||
}
|
||||
if resp.StatusCode == http.StatusPartialContent || resp.StatusCode == http.StatusOK {
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
} else {
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
}
|
||||
if resp.StatusCode == http.StatusPartialContent || resp.StatusCode == http.StatusOK {
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProxySubtitle 反向代理远程 Emby 字幕流。
|
||||
func (r *EmbyRemoteService) ProxySubtitle(ctx context.Context, w http.ResponseWriter, req *http.Request, acct *model.StrmAccount, remoteID, index string) error {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureToken(ctx, acct, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
upstream.Header.Set("X-Emby-Token", cfg.Token)
|
||||
resp, err := r.http.Do(upstream)
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接远程 Emby 字幕流失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("远程 Emby 字幕流失败(%d)", resp.StatusCode)
|
||||
}
|
||||
if value := resp.Header.Get("Content-Type"); value != "" {
|
||||
w.Header().Set("Content-Type", value)
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
_, _ = io.Copy(w, resp.Body)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── 播放状态透传 ──────────────────────────────────────────────────────────────
|
||||
|
||||
// ProxySetPlayed 把「已看/未看」状态透传到远程 Emby(MMTL 本地不落库)。
|
||||
func (r *EmbyRemoteService) ProxySetPlayed(ctx context.Context, acct *model.StrmAccount, remoteID string, played bool) error {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureToken(ctx, acct, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
method := http.MethodPost
|
||||
path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/PlayedItems/" + url.PathEscape(remoteID)
|
||||
if !played {
|
||||
method = http.MethodDelete
|
||||
}
|
||||
return r.doMutate(ctx, acct, cfg, method, path)
|
||||
}
|
||||
|
||||
// ProxySetFavorite 把「收藏/取消收藏」状态透传到远程 Emby。
|
||||
func (r *EmbyRemoteService) ProxySetFavorite(ctx context.Context, acct *model.StrmAccount, remoteID string, favorite bool) error {
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureToken(ctx, acct, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
method := http.MethodPost
|
||||
path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/FavoriteItems/" + url.PathEscape(remoteID)
|
||||
if !favorite {
|
||||
method = http.MethodDelete
|
||||
}
|
||||
return r.doMutate(ctx, acct, cfg, method, path)
|
||||
}
|
||||
|
||||
func (r *EmbyRemoteService) doMutate(ctx context.Context, acct *model.StrmAccount, 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 {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Emby-Token", cfg.Token)
|
||||
resp, err := r.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("请求远程 Emby 失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||
return fmt.Errorf("远程 Emby 状态同步失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// 远程 Emby 聚合的 ID 伪装。
|
||||
//
|
||||
// MMTL 作为 Emby 联邦网关把多个远程 Emby 服务器的媒体库透明聚合到自身的
|
||||
// Emby API 之下,远程条目完全不落库。为了把本地 ID 与多个远程服务器的 ID
|
||||
// 隔离开,远程条目在返回给客户端之前统一被改写为:
|
||||
//
|
||||
// embyremote~{accountID}~{remoteID}
|
||||
//
|
||||
// 客户端后续对图片 / 详情 / 播放 / 播放状态 的请求都会携带这个伪装 ID,
|
||||
// 服务端据此解码出对应账号与原始 ID,直接向远程 Emby 转发。
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EmbyRemoteIDPrefix 远程条目伪装 ID 的前缀(本地 UUID 与 Emby ID 不会出现 "~")。
|
||||
const EmbyRemoteIDPrefix = "embyremote~"
|
||||
|
||||
// IsEmbyRemoteID 报告 id 是否是伪装过的远程 Emby 条目 ID。
|
||||
func IsEmbyRemoteID(id string) bool {
|
||||
return strings.HasPrefix(id, EmbyRemoteIDPrefix)
|
||||
}
|
||||
|
||||
// EncodeEmbyRemoteID 把 (账号 ID, 远程条目 ID) 伪装为对外暴露的 ID。
|
||||
func EncodeEmbyRemoteID(accountID, remoteID string) string {
|
||||
return EmbyRemoteIDPrefix + accountID + "~" + remoteID
|
||||
}
|
||||
|
||||
// DecodeEmbyRemoteID 拆分伪装 ID 为 (账号 ID, 远程原始 ID)。不是伪装 ID 时返回
|
||||
// ok=false。远程 ID 本身允许包含 "~"(使用 SplitN 只切第一刀)。
|
||||
func DecodeEmbyRemoteID(id string) (accountID, remoteID string, ok bool) {
|
||||
if !IsEmbyRemoteID(id) {
|
||||
return "", "", false
|
||||
}
|
||||
rest := strings.TrimPrefix(id, EmbyRemoteIDPrefix)
|
||||
parts := strings.SplitN(rest, "~", 2)
|
||||
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
|
||||
// embyRemoteStringIDs 是条目 JSON 中需要伪装(编码)成远程 ID 的字符串字段。
|
||||
// 图片 / 详情 / 播放请求都会以这些字段的值作为 ID 回指 MMTL。
|
||||
var embyRemoteStringIDs = []string{
|
||||
"Id",
|
||||
"ParentId",
|
||||
"SeriesId",
|
||||
"SeasonId",
|
||||
"PrimaryImageItemId",
|
||||
"DisplayPreferencesId",
|
||||
}
|
||||
|
||||
// RewriteEmbyRemoteIDs 在内存中把远程 Emby 返回的载荷里的所有条目 ID 替换为
|
||||
// 伪装 ID(防止与本地、多远程冲突),嵌套 Items / Map 数组递归处理。
|
||||
//
|
||||
// MediaSources 里的 Id / MediaSourceId 保持不变:客户端只把它们作为查询
|
||||
// 参数原样带回,转发时直接送回远程即可。播放 URL 的重写由服务层
|
||||
// (rewriteEmbyRemotePlayURLs)按直连/代理模式处理。
|
||||
func RewriteEmbyRemoteIDs(value any, accountID string) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
rewriteEmbyRemoteIDsMap(typed, accountID)
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
RewriteEmbyRemoteIDs(item, accountID)
|
||||
}
|
||||
case []map[string]any:
|
||||
for _, item := range typed {
|
||||
rewriteEmbyRemoteIDsMap(item, accountID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rewriteEmbyRemoteIDsMap(m map[string]any, accountID string) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
for _, key := range embyRemoteStringIDs {
|
||||
if raw, ok := m[key].(string); ok && raw != "" {
|
||||
m[key] = EncodeEmbyRemoteID(accountID, raw)
|
||||
}
|
||||
}
|
||||
if tags, ok := m["ImageTags"].(map[string]any); ok {
|
||||
for k, v := range tags {
|
||||
if s, isStr := v.(string); isStr && s != "" {
|
||||
tags[k] = EncodeEmbyRemoteID(accountID, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if tags, ok := m["ImageTags"].(map[string]string); ok {
|
||||
for k, v := range tags {
|
||||
if v != "" {
|
||||
tags[k] = EncodeEmbyRemoteID(accountID, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if tags, ok := m["BackdropImageTags"].([]any); ok {
|
||||
for i := range tags {
|
||||
if s, isStr := tags[i].(string); isStr && s != "" {
|
||||
tags[i] = EncodeEmbyRemoteID(accountID, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if items, ok := m["Items"]; ok {
|
||||
RewriteEmbyRemoteIDs(items, accountID)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEmbyRemoteIDEncodeDecode(t *testing.T) {
|
||||
encoded := EncodeEmbyRemoteID("acct-1", "item-123")
|
||||
want := "embyremote~acct-1~item-123"
|
||||
if encoded != want {
|
||||
t.Fatalf("encoded = %q, want %q", encoded, want)
|
||||
}
|
||||
if !IsEmbyRemoteID(encoded) {
|
||||
t.Fatalf("IsEmbyRemoteID(%q) = false", encoded)
|
||||
}
|
||||
acctID, remoteID, ok := DecodeEmbyRemoteID(encoded)
|
||||
if !ok || acctID != "acct-1" || remoteID != "item-123" {
|
||||
t.Fatalf("decode = (%q, %q, %v)", acctID, remoteID, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeEmbyRemoteIDAllowsTildeInRemoteID(t *testing.T) {
|
||||
// 远程 ID 本身允许包含 "~":只切第一刀。
|
||||
encoded := EncodeEmbyRemoteID("acct-1", "a~b~c")
|
||||
acctID, remoteID, ok := DecodeEmbyRemoteID(encoded)
|
||||
if !ok || acctID != "acct-1" || remoteID != "a~b~c" {
|
||||
t.Fatalf("decode = (%q, %q, %v)", acctID, remoteID, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeEmbyRemoteIDRejectsLocalUUIDs(t *testing.T) {
|
||||
if _, _, ok := DecodeEmbyRemoteID("550e8400-e29b-41d4-a716-446655440000"); ok {
|
||||
t.Fatal("local UUID must not decode as remote id")
|
||||
}
|
||||
if _, _, ok := DecodeEmbyRemoteID("embyremote~only-acct"); ok {
|
||||
t.Fatal("malformed remote id must not decode")
|
||||
}
|
||||
if _, _, ok := DecodeEmbyRemoteID("embyremote~~"); ok {
|
||||
t.Fatal("empty parts must not decode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteEmbyRemoteIDs(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"Id": "item-1",
|
||||
"ParentId": "folder-1",
|
||||
"SeriesId": "series-1",
|
||||
"SeasonId": "season-1",
|
||||
"PrimaryImageItemId": "item-1",
|
||||
"DisplayPreferencesId": "folder-1",
|
||||
"ImageTags": map[string]any{
|
||||
"Primary": "item-1",
|
||||
},
|
||||
"BackdropImageTags": []any{"item-1-bd"},
|
||||
"Items": []any{
|
||||
map[string]any{"Id": "item-2", "ParentId": "folder-2"},
|
||||
},
|
||||
// MediaSource 的 Id 保持原样(客户端仅作为 MediaSourceId 查询参数)。
|
||||
"MediaSources": []any{
|
||||
map[string]any{
|
||||
"Id": "ms-9",
|
||||
"DirectStreamUrl": "/Videos/item-1/stream",
|
||||
"MediaStreams": []any{
|
||||
map[string]any{"Type": "Subtitle", "DeliveryUrl": "/Videos/item-1/Subtitles/2/Stream.srt"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
RewriteEmbyRemoteIDs(payload, "acct-1")
|
||||
|
||||
if got := payload["Id"]; got != "embyremote~acct-1~item-1" {
|
||||
t.Fatalf("Id = %v", got)
|
||||
}
|
||||
if got := payload["ParentId"]; got != "embyremote~acct-1~folder-1" {
|
||||
t.Fatalf("ParentId = %v", got)
|
||||
}
|
||||
if got := payload["SeriesId"]; got != "embyremote~acct-1~series-1" {
|
||||
t.Fatalf("SeriesId = %v", got)
|
||||
}
|
||||
if got := payload["SeasonId"]; got != "embyremote~acct-1~season-1" {
|
||||
t.Fatalf("SeasonId = %v", got)
|
||||
}
|
||||
if got := payload["ImageTags"].(map[string]any)["Primary"]; got != "embyremote~acct-1~item-1" {
|
||||
t.Fatalf("ImageTags.Primary = %v", got)
|
||||
}
|
||||
if got := payload["BackdropImageTags"].([]any)[0]; got != "embyremote~acct-1~item-1-bd" {
|
||||
t.Fatalf("BackdropImageTags[0] = %v", got)
|
||||
}
|
||||
nested := payload["Items"].([]any)[0].(map[string]any)
|
||||
if nested["Id"] != "embyremote~acct-1~item-2" {
|
||||
t.Fatalf("nested Id = %v", nested["Id"])
|
||||
}
|
||||
|
||||
// MediaSource.Id 与 URL 不被 ID 重写器触碰(URL 由代理模式函数改写)。
|
||||
ms := payload["MediaSources"].([]any)[0].(map[string]any)
|
||||
if ms["Id"] != "ms-9" {
|
||||
t.Fatalf("MediaSource.Id must stay raw, got %v", ms["Id"])
|
||||
}
|
||||
if ms["DirectStreamUrl"] != "/Videos/item-1/stream" {
|
||||
t.Fatalf("DirectStreamUrl must stay raw, got %v", ms["DirectStreamUrl"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// rewriteSubtitleDeliveryURLs 只应改动字幕轨道的 DeliveryUrl,其余媒体流不动。
|
||||
func TestRewriteSubtitleDeliveryURLsProxyMode(t *testing.T) {
|
||||
src := map[string]any{
|
||||
"MediaStreams": []any{
|
||||
map[string]any{"Type": "Video", "DeliveryUrl": "/Videos/x/stream"},
|
||||
map[string]any{"Type": "Audio", "DeliveryUrl": "/Videos/x/stream"},
|
||||
map[string]any{"Type": "Subtitle", "DeliveryUrl": "/Videos/item-1/ms-9/Subtitles/2/Stream.srt"},
|
||||
},
|
||||
}
|
||||
rewriteSubtitleDeliveryURLs(src, "/Videos/embyremote~acct-1~item-1", &EmbyRemoteConfig{})
|
||||
streams := src["MediaStreams"].([]any)
|
||||
if got := streams[0].(map[string]any)["DeliveryUrl"]; got != "/Videos/x/stream" {
|
||||
t.Fatalf("video DeliveryUrl must stay, got %v", got)
|
||||
}
|
||||
want := "/Videos/embyremote~acct-1~item-1/Subtitles/2/Stream.srt"
|
||||
if got := streams[2].(map[string]any)["DeliveryUrl"]; got != want {
|
||||
t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteSubtitleDeliveryURLsDirectMode(t *testing.T) {
|
||||
src := map[string]any{
|
||||
"MediaStreams": []any{
|
||||
map[string]any{"Type": "Subtitle", "DeliveryUrl": "/Videos/item-1/ms-9/Subtitles/1/Stream.ass"},
|
||||
},
|
||||
}
|
||||
cfg := &EmbyRemoteConfig{Token: "tok123"}
|
||||
rewriteSubtitleDeliveryURLs(src, "http://remote:8096/emby/Videos/item-1", cfg)
|
||||
streams := src["MediaStreams"].([]any)
|
||||
want := "http://remote:8096/emby/Videos/item-1/Subtitles/1/Stream.ass?api_key=tok123"
|
||||
if got := streams[0].(map[string]any)["DeliveryUrl"]; got != want {
|
||||
t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteSubtitleDeliveryURLsFallsBackIndexOne(t *testing.T) {
|
||||
src := map[string]any{
|
||||
"MediaStreams": []any{
|
||||
map[string]any{"Type": "Subtitle", "DeliveryUrl": "custom/url"},
|
||||
},
|
||||
}
|
||||
rewriteSubtitleDeliveryURLs(src, "/Videos/embyremote~acct-1~item-1", &EmbyRemoteConfig{})
|
||||
streams := src["MediaStreams"].([]any)
|
||||
want := "/Videos/embyremote~acct-1~item-1/Subtitles/1/Stream"
|
||||
if got := streams[0].(map[string]any)["DeliveryUrl"]; got != want {
|
||||
t.Fatalf("subtitle DeliveryUrl = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
@@ -124,7 +126,8 @@ func (e *EmbyService) userPayload(u *model.User) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// Views 返回 Emby 中"虚拟根目录"——每个 library 一个条目。
|
||||
// Views 返回 Emby 中"虚拟根目录"——每个 library 一个条目,外加所有启用的
|
||||
// 远程 Emby 挂载的媒体库(联邦聚合)。
|
||||
func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any, error) {
|
||||
libs, err := e.repo.Library.List(ctx)
|
||||
if err != nil {
|
||||
@@ -132,16 +135,129 @@ func (e *EmbyService) Views(ctx context.Context, userID string) (map[string]any,
|
||||
}
|
||||
libs = FilterDisplayCloudLibraries(ctx, e.repo, libs)
|
||||
visibility := e.mediaVisibility(ctx, userID)
|
||||
items := make([]map[string]any, 0, len(libs))
|
||||
items := make([]map[string]any, 0, len(libs)+4)
|
||||
for _, l := range libs {
|
||||
if !e.libraryVisibleFromCachedVisibility(l, visibility) {
|
||||
continue
|
||||
}
|
||||
items = append(items, e.libraryAsView(ctx, &l))
|
||||
}
|
||||
for _, remote := range e.remoteViews(ctx) {
|
||||
items = append(items, remote)
|
||||
}
|
||||
return map[string]any{"Items": items, "TotalRecordCount": len(items), "StartIndex": 0}, nil
|
||||
}
|
||||
|
||||
// remoteViews 拉取全部启用远程 Emby 账号的媒体库视图。单个账号失败只跳过
|
||||
// 该账号(日志记录),不影响其他来源。
|
||||
func (e *EmbyService) remoteViews(ctx context.Context) []map[string]any {
|
||||
if e == nil || e.remote == nil {
|
||||
return nil
|
||||
}
|
||||
accounts, err := e.remote.ListAccounts(ctx)
|
||||
if err != nil || len(accounts) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]map[string]any, 0, len(accounts)*2)
|
||||
for i := range accounts {
|
||||
acct := &accounts[i]
|
||||
views, err := e.remote.RemoteViews(ctx, acct)
|
||||
if err != nil {
|
||||
if e.log != nil {
|
||||
e.log.Warn("fetch remote emby views failed",
|
||||
zap.String("account", acct.Name), zap.Error(err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, v := range views {
|
||||
if view := e.remoteLibraryAsView(v, acct); view != nil {
|
||||
out = append(out, view)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// remoteLibraryAsView 把远程媒体的 CollectionFolder 视图标准化为本地视图
|
||||
// 同构的 payload(ID 伪装、名称前缀账号名以便区分多个远程来源)。
|
||||
func (e *EmbyService) remoteLibraryAsView(raw map[string]any, acct *model.StrmAccount) map[string]any {
|
||||
if raw == nil {
|
||||
return nil
|
||||
}
|
||||
remoteID, _ := raw["Id"].(string)
|
||||
if remoteID == "" {
|
||||
return nil
|
||||
}
|
||||
name, _ := raw["Name"].(string)
|
||||
if name == "" {
|
||||
name = acct.Name
|
||||
}
|
||||
collectionType, _ := raw["CollectionType"].(string)
|
||||
if !isSupportedEmbyCollectionType(collectionType) {
|
||||
collectionType = "mixed"
|
||||
}
|
||||
encoded := EncodeEmbyRemoteID(acct.ID, remoteID)
|
||||
imageTags := map[string]string{}
|
||||
if primary := embyRemoteImageTagsPrimary(raw); primary != "" {
|
||||
imageTags["Primary"] = encoded
|
||||
}
|
||||
return map[string]any{
|
||||
"Id": encoded,
|
||||
"Name": acct.Name + " · " + name,
|
||||
"CollectionType": collectionType,
|
||||
"ServerId": embyServerID,
|
||||
"Type": "CollectionFolder",
|
||||
"IsFolder": true,
|
||||
"Path": "",
|
||||
"SortName": strings.ToLower(name),
|
||||
"DateCreated": time.Now().UTC().Format(time.RFC3339),
|
||||
"CanDelete": false,
|
||||
"CanDownload": false,
|
||||
"DisplayPreferencesId": encoded,
|
||||
"PrimaryImageItemId": encoded,
|
||||
"PrimaryImageAspectRatio": 1.7777777777777777,
|
||||
"RecursiveItemCount": 0,
|
||||
"ChildCount": 0,
|
||||
"SpecialFeatureCount": 0,
|
||||
"EnableMediaSourceDisplay": true,
|
||||
"PlayAccess": "Full",
|
||||
"ExternalUrls": []any{},
|
||||
"ProviderIds": map[string]string{},
|
||||
"Genres": []string{},
|
||||
"Tags": []string{},
|
||||
"ImageTags": imageTags,
|
||||
"BackdropImageTags": []string{},
|
||||
"UserData": map[string]any{
|
||||
"PlaybackPositionTicks": 0,
|
||||
"PlayCount": 0,
|
||||
"IsFavorite": false,
|
||||
"Played": false,
|
||||
"UnplayedItemCount": 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// embyRemoteImageTagsPrimary 提取远程 View 的 ImageTags.Primary 值。
|
||||
func embyRemoteImageTagsPrimary(raw map[string]any) string {
|
||||
switch tags := raw["ImageTags"].(type) {
|
||||
case map[string]any:
|
||||
if s, ok := tags["Primary"].(string); ok {
|
||||
return s
|
||||
}
|
||||
case map[string]string:
|
||||
return tags["Primary"]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isSupportedEmbyCollectionType(t string) bool {
|
||||
switch t {
|
||||
case "movies", "tvshows", "music", "mixed", "homevideos", "boxsets":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *EmbyService) libraryAsView(ctx context.Context, l *model.Library) map[string]any {
|
||||
collectionType := "movies"
|
||||
switch l.Type {
|
||||
|
||||
@@ -12,8 +12,16 @@ import (
|
||||
"github.com/ShukeBta/MMTL/internal/model"
|
||||
)
|
||||
|
||||
// SetFavorite 把 mediaID 标为 userID 的收藏。
|
||||
// SetFavorite 把 mediaID 标为 userID 的收藏。远程 Emby 条目直接透传到对应
|
||||
// 服务器(本地不落库)。
|
||||
func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, favorite bool) error {
|
||||
if e.remote != nil && IsEmbyRemoteID(mediaID) {
|
||||
acctID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||
if err := e.ProxyRemoteSetFavorite(ctx, acctID, remoteID, favorite); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if favorite {
|
||||
var f model.Favorite
|
||||
err := e.repo.DB.WithContext(ctx).
|
||||
@@ -31,7 +39,15 @@ func (e *EmbyService) SetFavorite(ctx context.Context, userID, mediaID string, f
|
||||
}
|
||||
|
||||
// MarkPlayed 把 mediaID 标为已看(写一个 100% 进度的 history 行)。
|
||||
// 远程 Emby 条目直接透传到对应服务器(本地不落库)。
|
||||
func (e *EmbyService) MarkPlayed(ctx context.Context, userID, mediaID string, played bool) error {
|
||||
if e.remote != nil && IsEmbyRemoteID(mediaID) {
|
||||
acctID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
||||
if err := e.ProxyRemoteSetPlayed(ctx, acctID, remoteID, played); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !played {
|
||||
return e.repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND media_id = ?", userID, mediaID).
|
||||
|
||||
@@ -46,6 +46,7 @@ type Container struct {
|
||||
Scheduler *SchedulerService
|
||||
Storage *StorageService
|
||||
Emby *EmbyService
|
||||
EmbyRemote *EmbyRemoteService
|
||||
Backup *BackupService
|
||||
PlayProfiles *PlayProfileService
|
||||
Permissions *PermissionService
|
||||
|
||||
@@ -103,6 +103,8 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
b.c.DLNA = NewDLNAService(b.log)
|
||||
b.c.Storage = NewStorageService(b.log, b.repos)
|
||||
b.c.Emby = NewEmbyService(b.cfg, b.log, b.repos)
|
||||
b.c.EmbyRemote = NewEmbyRemoteService(b.cfg, b.log, b.repos, b.c.Crypto)
|
||||
b.c.Emby.SetEmbyRemote(b.c.EmbyRemote)
|
||||
b.c.Backup = NewBackupService(b.cfg, b.log, b.repos.DB)
|
||||
b.c.Media = NewMediaService(b.cfg, b.log, b.repos).SetRuntimeCache(b.c.Cache)
|
||||
b.c.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder)
|
||||
|
||||
@@ -73,7 +73,7 @@ var StrmSettingDefs = map[string]struct {
|
||||
}
|
||||
|
||||
// StrmAccountSecretKeys 是账号配置中需要加密存储的字段。
|
||||
var StrmAccountSecretKeys = []string{"cookie", "password", "token", "access_token", "refresh_token"}
|
||||
var StrmAccountSecretKeys = []string{"cookie", "password", "token", "access_token", "refresh_token", "api_key"}
|
||||
|
||||
// StrmService 提供 STRM 管理的能力。
|
||||
type StrmService struct {
|
||||
@@ -245,6 +245,10 @@ func HasStrmAccountCredential(acct *model.StrmAccount) bool {
|
||||
return strings.Contains(acct.Config, `"access_token"`)
|
||||
case model.StrmProviderOpenList:
|
||||
return strings.Contains(acct.Config, `"token"`) || strings.Contains(acct.Config, `"password"`)
|
||||
case model.StrmProviderEmbyRemote:
|
||||
// 远程 Emby:已保存接入地址与认证凭据(自动认证得到的 token 或手动 api_key)。
|
||||
return strings.Contains(acct.Config, `"url"`) &&
|
||||
(strings.Contains(acct.Config, `"token"`) || strings.Contains(acct.Config, `"api_key"`))
|
||||
default:
|
||||
return strings.Contains(acct.Config, `"password"`) || strings.Contains(acct.Config, `"token"`)
|
||||
}
|
||||
@@ -649,6 +653,8 @@ func providerLabel(provider string) string {
|
||||
return "OpenList"
|
||||
case model.StrmProviderLocal:
|
||||
return "本地目录"
|
||||
case model.StrmProviderEmbyRemote:
|
||||
return "Emby 远程挂载"
|
||||
default:
|
||||
return provider
|
||||
}
|
||||
@@ -660,6 +666,7 @@ var StrmProviderLabels = map[string]string{
|
||||
model.StrmProviderCloudDrive: "CloudDrive2",
|
||||
model.StrmProviderOpenList: "OpenList",
|
||||
model.StrmProviderLocal: "本地目录",
|
||||
model.StrmProviderEmbyRemote: "Emby 远程挂载",
|
||||
}
|
||||
|
||||
const defaultStrmUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36 MMTL-Strm/1.0"
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
QrCode,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Tv,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -90,6 +91,7 @@ const PROVIDER_OPTIONS: { provider: StrmProvider; label: string; desc: string }[
|
||||
{ provider: 'cloud115', label: '115 网盘', desc: '开放平台 OAuth 授权(扫码/网页)' },
|
||||
{ provider: 'clouddrive2', label: 'CloudDrive2', desc: 'WebDAV 桥接多种网盘' },
|
||||
{ provider: 'openlist', label: 'OpenList', desc: 'OpenList / AList 兼容服务' },
|
||||
{ provider: 'emby_remote', label: 'Emby 远程挂载', desc: '聚合远程 Emby 媒体库,不落库实时透传' },
|
||||
]
|
||||
|
||||
export function StrmAccountDialog({
|
||||
@@ -109,6 +111,7 @@ export function StrmAccountDialog({
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
const [proxyPlay, setProxyPlay] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const buildConfig = (): Record<string, string> => {
|
||||
@@ -126,6 +129,14 @@ export function StrmAccountDialog({
|
||||
...(password ? { password } : {}),
|
||||
...(token ? { token } : {}),
|
||||
}
|
||||
case 'emby_remote':
|
||||
return {
|
||||
...(url ? { url } : {}),
|
||||
...(username ? { username } : {}),
|
||||
...(password ? { password } : {}),
|
||||
...(token ? { token } : {}),
|
||||
proxy_play: proxyPlay ? 'true' : 'false',
|
||||
}
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
@@ -137,6 +148,8 @@ export function StrmAccountDialog({
|
||||
return url !== ''
|
||||
case 'openlist':
|
||||
return server !== '' && (token !== '' || password !== '')
|
||||
case 'emby_remote':
|
||||
return url !== '' && (token !== '' || password !== '')
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -182,7 +195,14 @@ export function StrmAccountDialog({
|
||||
{!existing && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{PROVIDER_OPTIONS.map((option) => {
|
||||
const Icon = option.provider === 'cloud115' ? QrCode : option.provider === 'clouddrive2' ? HardDrive : FolderPlus
|
||||
const Icon =
|
||||
option.provider === 'cloud115'
|
||||
? QrCode
|
||||
: option.provider === 'clouddrive2'
|
||||
? HardDrive
|
||||
: option.provider === 'emby_remote'
|
||||
? Tv
|
||||
: FolderPlus
|
||||
const active = provider === option.provider
|
||||
return (
|
||||
<button
|
||||
@@ -235,7 +255,13 @@ export function StrmAccountDialog({
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{provider !== 'cloud115' && (
|
||||
{provider === 'emby_remote' && (
|
||||
<Field label="Emby 服务地址" hint="远程 Emby 服务器地址,如 http://192.168.1.10:8096">
|
||||
<input className={inputCls} value={url} placeholder="http://host:8096" onChange={(e) => setUrl(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{(provider === 'clouddrive2' || provider === 'emby_remote') && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="用户名">
|
||||
<input className={inputCls} value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
@@ -246,12 +272,46 @@ export function StrmAccountDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === 'openlist' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="用户名">
|
||||
<input className={inputCls} value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="密码">
|
||||
<input className={inputCls} type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === 'emby_remote' && (
|
||||
<Field label="API Key(可选)" hint="留空则用下方用户名/密码自动认证获取">
|
||||
<input className={inputCls} value={token} placeholder="留空自动认证" onChange={(e) => setToken(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{provider === 'openlist' && (
|
||||
<Field label="Token(可选,优先于密码)">
|
||||
<input className={inputCls} value={token} onChange={(e) => setToken(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{provider === 'emby_remote' && (
|
||||
<label className="flex cursor-pointer items-start gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4 accent-primary-400"
|
||||
checked={proxyPlay}
|
||||
onChange={(e) => setProxyPlay(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-semibold text-ink-600">播放流量经过本服务器代理</span>
|
||||
<span className="block text-xs text-sand-500">
|
||||
关闭(推荐):播放时客户端直连远程 Emby,本服务器不参与流量中转
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{existing && provider !== 'cloud115' && (
|
||||
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
|
||||
<input
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Trash2,
|
||||
Tv,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -55,6 +56,8 @@ export function providerIcon(provider: StrmProvider) {
|
||||
return FolderPlus
|
||||
case 'local':
|
||||
return HardDrive
|
||||
case 'emby_remote':
|
||||
return Tv
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// STRM 管理相关类型定义(与后端 internal/model/strm.go 对应)。
|
||||
export type StrmProvider = 'cloud115' | 'clouddrive2' | 'openlist' | 'local'
|
||||
export type StrmProvider = 'cloud115' | 'clouddrive2' | 'openlist' | 'local' | 'emby_remote'
|
||||
|
||||
export const STRM_PROVIDER_LABELS: Record<StrmProvider, string> = {
|
||||
cloud115: '115 网盘',
|
||||
clouddrive2: 'CloudDrive2',
|
||||
openlist: 'OpenList',
|
||||
local: '本地目录',
|
||||
emby_remote: 'Emby 远程挂载',
|
||||
}
|
||||
|
||||
export interface StrmAccount {
|
||||
|
||||
Reference in New Issue
Block a user