mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
396 lines
13 KiB
Go
396 lines
13 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/truewhile/MeBox/internal/model"
|
|
)
|
|
|
|
// PlaybackInfo returns a PlaybackInfoResponse usable by Emby clients.
|
|
// 远程 Emby 条目直接转发远程 PlaybackInfo,并按账号 proxy_play 配置决定
|
|
// 播放地址指向远程(直连)还是 MeBox 本地代理端点。
|
|
func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string) (map[string]any, error) {
|
|
if e.remote != nil && IsEmbyRemoteID(mediaID) {
|
|
mountID, remoteID, _ := DecodeEmbyRemoteID(mediaID)
|
|
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
|
|
if err != nil {
|
|
return nil, ErrEmbyRemoteNotFound
|
|
}
|
|
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
|
|
return nil, ErrEmbyRemoteNotFound
|
|
}
|
|
out, err := e.remote.RemotePlaybackInfo(ctx, mount, acct, remoteID, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if out == nil {
|
|
return nil, ErrEmbyRemoteNotFound
|
|
}
|
|
if err := e.mergeRemoteUserData(ctx, userID, out); err != nil {
|
|
return nil, err
|
|
}
|
|
out["PlaySessionId"] = fmt.Sprintf("remote-%s-%d", mountID, time.Now().UnixMilli())
|
|
return out, nil
|
|
}
|
|
m, err := e.playableMedia(ctx, mediaID, userID)
|
|
if err != nil || m == nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{
|
|
"MediaSources": e.mediaSourcesForItem(ctx, m, false, e.directPlayOnly(ctx)),
|
|
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().UnixMilli()),
|
|
}, 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, mountID, remoteID string) error {
|
|
if e == nil || e.remote == nil {
|
|
return ErrEmbyRemoteNotFound
|
|
}
|
|
_, acct, err := e.remote.ResolveMount(ctx, mountID)
|
|
if err != 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, mountID, remoteID, index string) error {
|
|
if e == nil || e.remote == nil {
|
|
return ErrSubtitleNotFound
|
|
}
|
|
_, acct, err := e.remote.ResolveMount(ctx, mountID)
|
|
if err != nil {
|
|
return ErrSubtitleNotFound
|
|
}
|
|
return e.remote.ProxySubtitle(ctx, w, r, acct, remoteID, index)
|
|
}
|
|
|
|
// ProxyRemoteSetPlayed 把已看/未看状态透传到远程 Emby。
|
|
func (e *EmbyService) ProxyRemoteSetPlayed(ctx context.Context, mountID, remoteID string, played bool) error {
|
|
if e == nil || e.remote == nil {
|
|
return ErrEmbyRemoteNotFound
|
|
}
|
|
_, acct, err := e.remote.ResolveMount(ctx, mountID)
|
|
if err != nil {
|
|
return ErrEmbyRemoteNotFound
|
|
}
|
|
return e.remote.ProxySetPlayed(ctx, acct, remoteID, played)
|
|
}
|
|
|
|
// ProxyRemoteSetFavorite 把收藏/取消收藏状态透传到远程 Emby。
|
|
func (e *EmbyService) ProxyRemoteSetFavorite(ctx context.Context, mountID, remoteID string, favorite bool) error {
|
|
if e == nil || e.remote == nil {
|
|
return ErrEmbyRemoteNotFound
|
|
}
|
|
_, acct, err := e.remote.ResolveMount(ctx, mountID)
|
|
if err != 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
|
|
// advertised in MediaStreams. The index mapping mirrors appendSubtitleStreams
|
|
// so the DeliveryUrl advertised in MediaStreams lines up exactly with the
|
|
// served track: subtitles start at 1 when no audio stream is present, otherwise
|
|
// at 2 (after Video 0 + Audio 1).
|
|
//
|
|
// 只服务外挂字幕文件(DiscoverExternalOnly):云盘/strm 媒体的容器内嵌字幕
|
|
// 不做服务端提取,客户端直连播放直链时自行解析。
|
|
func (e *EmbyService) ServeSubtitleStream(ctx context.Context, w io.Writer, mediaID, indexStr string, userID string) error {
|
|
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
|
|
}
|
|
index, err := strconv.Atoi(strings.TrimSpace(indexStr))
|
|
if err != nil || index < 1 {
|
|
return ErrSubtitleNotFound
|
|
}
|
|
tracks, err := e.subtitle.DiscoverExternalOnly(ctx, m.ID)
|
|
if err != nil || len(tracks) == 0 {
|
|
return ErrSubtitleNotFound
|
|
}
|
|
first := 1
|
|
if strings.TrimSpace(m.AudioCodec) != "" {
|
|
first = 2
|
|
}
|
|
offset := index - first
|
|
if offset < 0 || offset >= len(tracks) {
|
|
return ErrSubtitleNotFound
|
|
}
|
|
return e.subtitle.ServeRaw(ctx, m.ID, tracks[offset].Path, w)
|
|
}
|
|
|
|
// SubtitleStreamCodec resolves the source codec (ass/subrip/vtt/ssa) for a
|
|
// given Emby subtitle index so the handler can set an accurate Content-Type
|
|
// header before streaming the raw bytes.
|
|
func (e *EmbyService) SubtitleStreamCodec(ctx context.Context, mediaID, indexStr, userID string) string {
|
|
if e == nil || e.subtitle == nil {
|
|
return ""
|
|
}
|
|
m, err := e.playableMedia(ctx, mediaID, userID)
|
|
if err != nil || m == nil {
|
|
return ""
|
|
}
|
|
index, err := strconv.Atoi(strings.TrimSpace(indexStr))
|
|
if err != nil || index < 1 {
|
|
return ""
|
|
}
|
|
tracks, err := e.subtitle.DiscoverExternalOnly(ctx, m.ID)
|
|
if err != nil || len(tracks) == 0 {
|
|
return ""
|
|
}
|
|
first := 1
|
|
if strings.TrimSpace(m.AudioCodec) != "" {
|
|
first = 2
|
|
}
|
|
offset := index - first
|
|
if offset < 0 || offset >= len(tracks) {
|
|
return ""
|
|
}
|
|
return subtitleCodecFromExt(tracks[offset].Codec)
|
|
}
|
|
|
|
var (
|
|
// ErrSubtitleUnavailable is returned when the Emby shim has no subtitle
|
|
// service wired and therefore cannot serve external subtitle streams.
|
|
ErrSubtitleUnavailable = fmt.Errorf("subtitle service unavailable")
|
|
// ErrSubtitleNotFound is returned when a requested subtitle index does not
|
|
// map to a discovered external subtitle track.
|
|
ErrSubtitleNotFound = fmt.Errorf("subtitle not found")
|
|
)
|
|
|
|
// ensureCloudTrackMetadata 在后台补齐云盘媒体的轨道元数据。
|
|
//
|
|
// 注意必须是异步的:此前这里在 PlaybackInfo 请求路径上同步执行
|
|
// CloudResolve + ffprobe(HTTP)(最长 8 秒),既把第三方播放器的起播时间
|
|
// 拖长到秒级,又让每一次点开详情/起播都可能触发一次云盘数据下载,是
|
|
// Docker 部署下 CPU/带宽长期居高的来源之一。探测结果落库后,下一次
|
|
// 请求自然能读到完整元数据。
|
|
func mediaTrackMetadataMissing(m *model.Media) bool {
|
|
return m.DurationSec <= 0 ||
|
|
m.Width <= 0 ||
|
|
m.Height <= 0 ||
|
|
strings.TrimSpace(m.VideoCodec) == "" ||
|
|
strings.TrimSpace(m.AudioCodec) == ""
|
|
}
|
|
|
|
func parseCloudMediaPlaybackURL(raw string) (string, string, bool) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return "", "", false
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return "", "", false
|
|
}
|
|
path := strings.Trim(u.Path, "/")
|
|
const prefix = "api/cloud/play/"
|
|
idx := strings.Index(strings.ToLower(path), prefix)
|
|
if idx < 0 {
|
|
return "", "", false
|
|
}
|
|
typ := strings.TrimSpace(path[idx+len(prefix):])
|
|
ref := strings.TrimSpace(u.Query().Get("ref"))
|
|
return typ, ref, typ != "" && ref != ""
|
|
}
|
|
|
|
func applyProbeResultToMediaValue(m *model.Media, probe *ProbeResult) {
|
|
if m == nil || probe == nil {
|
|
return
|
|
}
|
|
if probe.DurationSec > 0 {
|
|
m.DurationSec = probe.DurationSec
|
|
}
|
|
if probe.Width > 0 {
|
|
m.Width = probe.Width
|
|
}
|
|
if probe.Height > 0 {
|
|
m.Height = probe.Height
|
|
}
|
|
if strings.TrimSpace(probe.VideoCodec) != "" {
|
|
m.VideoCodec = probe.VideoCodec
|
|
}
|
|
if strings.TrimSpace(probe.AudioCodec) != "" {
|
|
m.AudioCodec = probe.AudioCodec
|
|
}
|
|
if strings.TrimSpace(probe.Container) != "" {
|
|
m.Container = probe.Container
|
|
}
|
|
}
|
|
|
|
// directPlayOnly reports whether the admin enabled「客户端直连解码」mode.
|
|
// In that mode the host never transcodes; clients must direct-play.
|
|
func (e *EmbyService) directPlayOnly(ctx context.Context) bool {
|
|
if e.repo == nil || e.repo.Setting == nil {
|
|
return false
|
|
}
|
|
v, err := e.repo.Setting.Get(ctx, PlaybackDirectOnlySettingKey)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return parseBoolSetting(v, false)
|
|
}
|
|
|
|
func (e *EmbyService) playableMedia(ctx context.Context, id, userID string) (*model.Media, error) {
|
|
if season, ok, err := e.findSeasonGroup(ctx, id, userID); err != nil {
|
|
return nil, err
|
|
} else if ok && len(season.Episodes) > 0 {
|
|
return &season.Episodes[0], nil
|
|
}
|
|
if series, ok, err := e.findSeriesGroup(ctx, id, userID); err != nil {
|
|
return nil, err
|
|
} else if ok && len(series.Episodes) > 0 {
|
|
return &series.Episodes[0], nil
|
|
}
|
|
m, err := e.repo.Media.FindByID(ctx, id)
|
|
if err != nil || m == nil {
|
|
return m, err
|
|
}
|
|
if !UserDefaultMediaVisibility(ctx, e.repo, userID).Allows(m) {
|
|
return nil, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// mediaSource 是 /Items 与 /PlaybackInfo 共享的 MediaSource 结构。
|
|
//
|
|
// asEmbedded=true:嵌在 /Items 列表里,不包含完整 stream URL(避免暴露
|
|
// 直链给搜索接口)。/PlaybackInfo 走 false 路径,URL 指向 Emby 兼容
|
|
// /Videos/{id}/stream(客户端会继续携带 X-Emby-Token 或 append api_key)。
|
|
func (e *EmbyService) mediaSource(ctx context.Context, m *model.Media, asEmbedded, directOnly bool) map[string]any {
|
|
container := embyMediaContainer(m)
|
|
isCloud := strings.TrimSpace(m.STRMURL) != ""
|
|
playURL := e.embyMediaPlayURL(ctx, m, container, isCloud)
|
|
if isCloud {
|
|
// Cloud/WebDAV media is already a direct/proxy stream. Advertising HLS
|
|
// transcoding makes some Emby clients pick /master.m3u8, forcing this
|
|
// lightweight server to pull remote bytes through ffmpeg and often
|
|
// surfacing as "network/playback failed". Keep cloud media direct-only.
|
|
directOnly = true
|
|
}
|
|
src := e.baseMediaSource(ctx, m, container, isCloud, playURL, directOnly)
|
|
if !asEmbedded && playURL != "" {
|
|
src["DirectStreamUrl"] = playURL
|
|
// 直连解码模式下不下发 TranscodingUrl,迫使客户端本地解码直连,
|
|
// 宿主机不参与转码。
|
|
if !directOnly {
|
|
src["TranscodingUrl"] = "/Videos/" + m.ID + "/master.m3u8"
|
|
}
|
|
}
|
|
if strings.TrimSpace(m.STRMURL) != "" && playURL != "" {
|
|
// STRM / cloud:// media must stay behind a token-aware endpoint. When
|
|
// STRM playback is enabled we expose /api/stream so third-party clients
|
|
// follow the same STRM entry as generated .strm files; when disabled we
|
|
// expose /Videos/{id}/stream so playback uses the Emby 302/proxy path.
|
|
src["IsRemote"] = true
|
|
}
|
|
return src
|
|
}
|
|
|
|
func (e *EmbyService) baseMediaSource(ctx context.Context, m *model.Media, container string, isCloud bool, playURL string, directOnly bool) map[string]any {
|
|
return map[string]any{
|
|
"Id": m.ID,
|
|
"Name": MediaVersionLabel(*m),
|
|
"Path": embyMediaSourcePath(m),
|
|
"Container": container,
|
|
"Size": m.SizeBytes,
|
|
"Protocol": "Http",
|
|
"Type": "Default",
|
|
"IsRemote": isCloud,
|
|
"RequiresOpening": false,
|
|
"RequiresClosing": false,
|
|
"ReadAtNativeFramerate": false,
|
|
"SupportsTranscoding": !directOnly,
|
|
"SupportsDirectStream": !isCloud || playURL != "",
|
|
"SupportsDirectPlay": !isCloud || playURL != "",
|
|
"SupportsProbing": true,
|
|
"RunTimeTicks": int64(m.DurationSec) * 10_000_000,
|
|
"MediaStreams": e.mediaStreams(ctx, m),
|
|
}
|
|
}
|
|
|
|
// embyMediaSourcePath keeps Emby's Path field as a source identity rather
|
|
// than a playback endpoint. OpenList and other path-based cloud providers
|
|
// carry the real provider path in the internal cloud-play ref query. For
|
|
// opaque provider refs, fall back to the display path stored in cloud://.
|
|
func embyMediaSourcePath(m *model.Media) string {
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
if _, ref, ok := parseCloudMediaPlaybackURL(m.STRMURL); ok && strings.HasPrefix(ref, "/") {
|
|
return ref
|
|
}
|
|
raw := strings.TrimSpace(m.Path)
|
|
u, err := url.Parse(raw)
|
|
if err != nil || !strings.EqualFold(u.Scheme, "cloud") {
|
|
return m.Path
|
|
}
|
|
sourcePath := strings.TrimSpace(u.Path)
|
|
if decoded, decodeErr := url.PathUnescape(sourcePath); decodeErr == nil {
|
|
sourcePath = decoded
|
|
}
|
|
if sourcePath == "" {
|
|
return "/"
|
|
}
|
|
if !strings.HasPrefix(sourcePath, "/") {
|
|
sourcePath = "/" + sourcePath
|
|
}
|
|
return sourcePath
|
|
}
|
|
|
|
func embyMediaContainer(m *model.Media) string {
|
|
container := strings.Trim(strings.ToLower(m.Container), ". ")
|
|
if container == "" {
|
|
container = strings.TrimPrefix(strings.ToLower(filepath.Ext(m.Path)), ".")
|
|
}
|
|
if container == "" && strings.TrimSpace(m.STRMURL) != "" {
|
|
return "strm"
|
|
}
|
|
return container
|
|
}
|
|
|
|
func (e *EmbyService) embyMediaPlayURL(ctx context.Context, m *model.Media, container string, isCloud bool) string {
|
|
if !isCloud {
|
|
return embyDirectStreamURL(m.ID, container)
|
|
}
|
|
switch CloudPlaybackMode(ctx, e.repo) {
|
|
case CloudPlaybackModeSTRM:
|
|
return embySTRMStreamURL(m.ID)
|
|
case CloudPlaybackModeRedirectProxy:
|
|
return embyDirectStreamURL(m.ID, container)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|