Files
truewhile 8136e6ecb1 fix(emby): 电影条目不下发空的 SeasonId/SeriesId,修复小幻影视起播失败
小幻影视(RodelPlayer)2.2610 会把电影条目的 SeasonId 空串原样传进自己的
播放准备逻辑并抛 ArgumentException(Parameter 'seasonId'),客户端日志里
表现为 failedStage=PreparationFailed、playback generation null,也就是
点播放立刻「播放失败」;剧集条目的 SeasonId 非空,所以同一台设备放剧集正常。

Emby/Jellyfin 对「不适用」的字段是直接不下发(JSON 里没有该键)而不是下发空串。
这里对非剧集条目省略 SeasonId/SeriesId/SeasonName/SeriesName(顺带去掉电影被
标成「特别篇」的 SeasonName),剧集则保留真实季/剧信息、只清空值。

同时回退上一版针对 115 直链 UA 绑定的同源一跳改动:客户端日志显示该客户端的
播放器层与 API 层 UA 是一致的(剧集走服务端换链后的一跳直连可以正常解码播放),
不需要为此牺牲起播速度。
2026-09-28 22:51:40 +08:00

163 lines
5.9 KiB
Go

package service
import (
"context"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// ServeFile streams the file backing the given media ID using
// http.ServeContent so HEAD / Range / If-Modified-Since are handled for free.
//
// When the media row has a STRMURL set we redirect (302) to that URL
// instead of opening a local file. This lets WebDAV / Alist / S3 / HTTP
// direct links flow through the rest of the player UI unchanged.
func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaID string) error {
return s.ServeFileWithCloudMode(w, r, mediaID, "")
}
func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Request, mediaID, cloudMode string) error {
m, err := s.repo.Media.FindByID(r.Context(), mediaID)
if err != nil {
return err
}
if m == nil {
return ErrMediaNotFound
}
if strmURL := strings.TrimSpace(m.STRMURL); strmURL != "" && playableSTRMTarget(r.Context(), s.repo, strmURL, m) {
if !cloudPlaybackModeEnabled(r.Context(), s.repo, cloudMode) {
return ErrCloudPlaybackDisabled
}
// 能在服务端换到最终直链就直接 302 过去:客户端少跟随一次 302,等于
// 省掉一次「DNS+TCP+TLS+请求」的往返。解析结果同时写进 strm 层直链
// 缓存,后续 /api/strm/play 请求直接命中。
if direct, ok := s.resolveDirectPlayTargetURL(r, strmURL); ok {
setCloudRedirectNoStore(w)
http.Redirect(w, r, direct, http.StatusFound)
return nil
}
// 云盘播放 URL 先规范化为相对路径,免疫扫描时固化的旧 host;
// 指向别的 MeBox 实例的地址保持原样,按第三方直链透传。
target := normalizeCloudPlayTarget(r.Context(), s.repo, s.cfg, r, strmURL)
target = withAuthTokenForInternalRedirect(target, r, PublicServerURL(r.Context(), s.repo, s.cfg))
setCloudRedirectNoStore(w)
http.Redirect(w, r, absoluteInternalRedirect(target, r), http.StatusFound)
return nil
}
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(m.Path)), "cloud://") {
// 云盘媒体没有本地文件可回退;走到这里说明 STRM 播放被关闭或
// STRMURL 缺失。返回明确错误而不是笼统的「文件不存在」,
// 处理器据此回 502 + 原因,方便用户在播放器/日志里定位。
return ErrCloudPlaybackUnavailable
}
f, err := os.Open(m.Path)
if err != nil {
return ErrMediaNotFound
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return err
}
w.Header().Set("Accept-Ranges", "bytes")
w.Header().Set("Content-Disposition", "inline")
w.Header().Set("X-Content-Type-Options", "nosniff")
http.ServeContent(w, r, stat.Name(), stat.ModTime(), f)
return nil
}
func setCloudRedirectNoStore(w http.ResponseWriter) {
if w == nil {
return
}
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
}
// directPlayResolveTimeout 是服务端换链的等待上限。115 开放平台在跨太平洋线路
// 上单次换链实测 0.4–1.1s,这里给足余量;一旦超时就回退到原来的 strm 端点跳转,
// 由 /api/strm/play 再去换链,最坏情况只是回到改动前的行为。
const directPlayResolveTimeout = 10 * time.Second
// resolveDirectPlayTargetURL 尝试在服务端把 strm 目标解析成客户端可直接拉取的
// 最终直链,供调用方直接 302。
//
// 只在「明确的直链」上短路:需要服务端反向代理(云盘 WebDAV 等必须附加请求头)、
// 解析到本地文件、以及解析失败都会返回 false,由调用方按改动前的方式回退到
// strm 端点跳转,行为不会变差。
func (s *StreamService) resolveDirectPlayTargetURL(r *http.Request, raw string) (string, bool) {
if s == nil || s.strmResolve == nil || r == nil {
return "", false
}
ctx, cancel := context.WithTimeout(r.Context(), directPlayResolveTimeout)
defer cancel()
// 必须带播放器 UA:115 直链绑定换取时的 UA,且按 UA 分键缓存,换错会拿到
// 与播放器不匹配(或未命中缓存)的地址。
result, err := s.strmResolve(ctx, raw, r.Header.Get("User-Agent"))
if err != nil || result == nil || result.Proxy || result.Link == nil {
return "", false
}
// 客户端只能自带 User-Agent 这类基础请求头。链接一旦要求其它头(Referer /
// Authorization),就必须继续由服务端反向代理,不能在这里短路。
for name := range result.Link.Headers {
if !strings.EqualFold(strings.TrimSpace(name), "User-Agent") {
return "", false
}
}
direct := strings.TrimSpace(result.RedirectURL)
if direct == "" {
return "", false
}
return direct, true
}
func isCloudPlaybackTarget(raw string) bool {
_, _, ok := parseCloudMediaPlaybackURL(raw)
return ok
}
func playableSTRMTarget(ctx context.Context, repo *repository.Container, raw string, m *model.Media) bool {
if isCloudPlaybackTarget(raw) || isHTTPPlaybackTarget(raw) {
return true
}
if m != nil && strings.EqualFold(strings.TrimSpace(m.Container), "strm") {
return true
}
return STRMPlaybackEnabled(ctx, repo)
}
// IsStrmMediaRow 判断媒体行是否为 .strm(远程直链)媒体:STRMURL 非空、
// container=strm 或路径以 .strm 结尾。网页播放默认直连,失败后可转码。
func IsStrmMediaRow(m *model.Media) bool {
if m == nil {
return false
}
if strings.TrimSpace(m.STRMURL) != "" {
return true
}
if strings.EqualFold(strings.TrimSpace(m.Container), "strm") {
return true
}
return strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm")
}
func isStrmMediaRow(m *model.Media) bool {
return IsStrmMediaRow(m)
}
func isHTTPPlaybackTarget(raw string) bool {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u == nil || !u.IsAbs() {
return false
}
scheme := strings.ToLower(strings.TrimSpace(u.Scheme))
return scheme == "http" || scheme == "https"
}