mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-30 11:46:36 +08:00
0eb3f104f4
- 新增 emby_remote 提供方:通过 STRM 账号体系配置远程 Emby 地址/用户名/密码
- EmbyService 聚合远程媒体库 Views/Items/详情/最近添加/全局搜索,远程数据不落库
- 条目 ID 统一伪装为 embyremote~{account}~{id},请求按账号路由回远程
- 播放支持账号级 proxy_play 配置:默认客户端直连远程;开启后由 MMTL 反向代理流/字幕
- 图片/元数据全部实时透传远程,播放状态(已看/收藏)透传回远程 Emby
- cloud 包新增 emby Provider(Ping/List/Resolve),账号测试/目录浏览自动生效
- 前端 STRM 页面支持 Emby 远程挂载账号(地址+凭据+代理开关)
650 lines
22 KiB
Go
650 lines
22 KiB
Go
// 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
|
||
} |