mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Revert "feat(playback): client direct-play decode mode (release host transcoding)"
This reverts commit 8fd2ab4b51.
This commit is contained in:
@@ -24,21 +24,14 @@ var startedAt = time.Now()
|
||||
|
||||
func systemInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
directOnly := false
|
||||
if svc.Repo != nil && svc.Repo.Setting != nil {
|
||||
if v, err := svc.Repo.Setting.Get(c.Request.Context(), service.PlaybackDirectOnlySettingKey); err == nil {
|
||||
directOnly = service.ParseBoolSetting(v, false)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"name": "MediaStationGo",
|
||||
"version": "0.1.0",
|
||||
"go": runtime.Version(),
|
||||
"os": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"data_dir": svc.Cfg.App.DataDir,
|
||||
"cache_dir": svc.Cfg.Cache.CacheDir,
|
||||
"direct_play_only": directOnly,
|
||||
"name": "MediaStationGo",
|
||||
"version": "0.1.0",
|
||||
"go": runtime.Version(),
|
||||
"os": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"data_dir": svc.Cfg.App.DataDir,
|
||||
"cache_dir": svc.Cfg.Cache.CacheDir,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,11 +36,6 @@ import (
|
||||
// 该 id 后会把所有派生数据(cookie/收藏/历史)和它绑定。
|
||||
const embyServerID = "mediastation-go-001"
|
||||
|
||||
// PlaybackDirectOnlySettingKey 控制「客户端直连解码」模式:开启后宿主机
|
||||
// 不再提供转码,所有播放交给第三方客户端本地解码(direct play / 302 直链),
|
||||
// 以释放宿主机 CPU 资源。
|
||||
const PlaybackDirectOnlySettingKey = "playback.direct_only"
|
||||
|
||||
// EmbyService produces Emby-shaped JSON.
|
||||
type EmbyService struct {
|
||||
cfg *config.Config
|
||||
@@ -649,7 +644,7 @@ func (e *EmbyService) itemPayload(m *model.Media, fav bool, posMs int64) map[str
|
||||
"Played": played,
|
||||
"PlayedPercentage": pct,
|
||||
},
|
||||
"MediaSources": []map[string]any{e.mediaSource(m, true, false)},
|
||||
"MediaSources": []map[string]any{e.mediaSource(m, true)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1154,24 +1149,11 @@ func (e *EmbyService) PlaybackInfo(ctx context.Context, mediaID, userID string)
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"MediaSources": []map[string]any{e.mediaSource(m, false, e.directPlayOnly(ctx))},
|
||||
"MediaSources": []map[string]any{e.mediaSource(m, false)},
|
||||
"PlaySessionId": fmt.Sprintf("%s-%d", m.ID, time.Now().Unix()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1198,7 +1180,7 @@ func (e *EmbyService) playableMedia(ctx context.Context, id, userID string) (*mo
|
||||
// asEmbedded=true:嵌在 /Items 列表里,不包含完整 stream URL(避免暴露
|
||||
// 直链给搜索接口)。/PlaybackInfo 走 false 路径,URL 指向 Emby 兼容
|
||||
// /Videos/{id}/stream(客户端会继续携带 X-Emby-Token 或 append api_key)。
|
||||
func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) map[string]any {
|
||||
func (e *EmbyService) mediaSource(m *model.Media, asEmbedded bool) map[string]any {
|
||||
src := map[string]any{
|
||||
"Id": m.ID,
|
||||
"Name": m.Title,
|
||||
@@ -1208,7 +1190,7 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) m
|
||||
"Protocol": "Http",
|
||||
"Type": "Default",
|
||||
"IsRemote": false,
|
||||
"SupportsTranscoding": !directOnly,
|
||||
"SupportsTranscoding": true,
|
||||
"SupportsDirectStream": true,
|
||||
"SupportsDirectPlay": true,
|
||||
"SupportsProbing": true,
|
||||
@@ -1217,11 +1199,7 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) m
|
||||
}
|
||||
if !asEmbedded {
|
||||
src["DirectStreamUrl"] = "/Videos/" + m.ID + "/stream"
|
||||
// 直连解码模式下不下发 TranscodingUrl,迫使客户端本地解码直连,
|
||||
// 宿主机不参与转码。
|
||||
if !directOnly {
|
||||
src["TranscodingUrl"] = "/Videos/" + m.ID + "/stream"
|
||||
}
|
||||
src["TranscodingUrl"] = "/Videos/" + m.ID + "/stream"
|
||||
}
|
||||
if strings.TrimSpace(m.STRMURL) != "" {
|
||||
// STRM 重定向:客户端直接拉远端,跳过我们这一层。
|
||||
|
||||
@@ -202,50 +202,6 @@ func TestEmbyHidesAdultLibrariesForUserLock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbyPlaybackInfoRespectsDirectPlayOnly(t *testing.T) {
|
||||
svc := newTestEmbyService(t)
|
||||
lib := model.Library{Name: "电影", Path: `/media/movies`, Type: "movie", Enabled: true}
|
||||
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
|
||||
t.Fatalf("create library: %v", err)
|
||||
}
|
||||
media := model.Media{Base: model.Base{ID: "m-1"}, LibraryID: lib.ID, Title: "Inception", Path: `/media/movies/inception.mkv`}
|
||||
if err := svc.repo.DB.Create(&media).Error; err != nil {
|
||||
t.Fatalf("create media: %v", err)
|
||||
}
|
||||
|
||||
// 默认(关闭):宿主机可转码,下发 TranscodingUrl。
|
||||
pb, err := svc.PlaybackInfo(t.Context(), "m-1", "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("playback info: %v", err)
|
||||
}
|
||||
src := pb["MediaSources"].([]map[string]any)[0]
|
||||
if src["SupportsTranscoding"] != true {
|
||||
t.Fatalf("expected SupportsTranscoding=true by default, got %#v", src["SupportsTranscoding"])
|
||||
}
|
||||
if _, ok := src["TranscodingUrl"]; !ok {
|
||||
t.Fatalf("expected TranscodingUrl present by default: %#v", src)
|
||||
}
|
||||
|
||||
// 开启「客户端直连解码」:不再下发转码能力 / TranscodingUrl,仍保留 DirectStream。
|
||||
if err := svc.repo.Setting.Set(t.Context(), PlaybackDirectOnlySettingKey, "true"); err != nil {
|
||||
t.Fatalf("enable direct-only: %v", err)
|
||||
}
|
||||
pb, err = svc.PlaybackInfo(t.Context(), "m-1", "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("playback info (direct-only): %v", err)
|
||||
}
|
||||
src = pb["MediaSources"].([]map[string]any)[0]
|
||||
if src["SupportsTranscoding"] != false {
|
||||
t.Fatalf("expected SupportsTranscoding=false in direct-only mode, got %#v", src["SupportsTranscoding"])
|
||||
}
|
||||
if _, ok := src["TranscodingUrl"]; ok {
|
||||
t.Fatalf("expected no TranscodingUrl in direct-only mode: %#v", src)
|
||||
}
|
||||
if src["SupportsDirectPlay"] != true || src["DirectStreamUrl"] != "/Videos/m-1/stream" {
|
||||
t.Fatalf("direct-only must still allow direct play: %#v", src)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestEmbyService(t *testing.T) *EmbyService {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
|
||||
@@ -75,12 +75,6 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ParseBoolSetting is the exported variant of parseBoolSetting for handlers
|
||||
// that need to interpret persisted on/off settings (accepts zh + en tokens).
|
||||
func ParseBoolSetting(value string, fallback bool) bool {
|
||||
return parseBoolSetting(value, fallback)
|
||||
}
|
||||
|
||||
func parseBoolSetting(value string, fallback bool) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on", "enabled", "启用", "开启":
|
||||
|
||||
@@ -54,20 +54,6 @@ func NewStreamService(cfg *config.Config, log *zap.Logger, repo *repository.Cont
|
||||
// ErrMediaNotFound is returned when the media row or its file is missing.
|
||||
var ErrMediaNotFound = errors.New("media not found")
|
||||
|
||||
// directPlayOnly reports whether the admin enabled「客户端直连解码」mode,
|
||||
// in which the host never transcodes (HLS is refused) and all playback is
|
||||
// handled by the client (direct play / 302 redirect).
|
||||
func (s *StreamService) directPlayOnly(ctx context.Context) bool {
|
||||
if s.repo == nil || s.repo.Setting == nil {
|
||||
return false
|
||||
}
|
||||
v, err := s.repo.Setting.Get(ctx, PlaybackDirectOnlySettingKey)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return parseBoolSetting(v, false)
|
||||
}
|
||||
|
||||
// ServeFile streams the file backing the given media ID using
|
||||
// http.ServeContent so HEAD / Range / If-Modified-Since are handled for free.
|
||||
//
|
||||
@@ -105,11 +91,6 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
|
||||
// ServeHLSPlaylist makes sure a transcode is running and writes the m3u8.
|
||||
// We block (with a 30s timeout) until the playlist file shows up.
|
||||
func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, mediaID string) error {
|
||||
// 「客户端直连解码」模式下宿主机不提供转码,HLS 一律拒绝,
|
||||
// 迫使播放器走 direct play 本地解码。
|
||||
if s.directPlayOnly(r.Context()) {
|
||||
return ErrTranscodeDisabled
|
||||
}
|
||||
if _, err := s.transcoder.EnsureJob(r.Context(), mediaID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ export const systemAPI = {
|
||||
arch: string
|
||||
data_dir: string
|
||||
cache_dir: string
|
||||
direct_play_only?: boolean
|
||||
}>('/system/info')
|
||||
.then((r) => r.data),
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { mediaAPI } from '../api/library'
|
||||
import { api, hlsURL, streamURL } from '../api/client'
|
||||
import { playbackAPI } from '../api/playback'
|
||||
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { systemAPI } from '../api/system'
|
||||
import type { Media } from '../types'
|
||||
|
||||
type Mode = 'direct' | 'hls'
|
||||
@@ -38,16 +37,6 @@ export function PlayerPage() {
|
||||
const [subs, setSubs] = useState<SubtitleTrack[]>([])
|
||||
const [hlsUnavailable, setHlsUnavailable] = useState(false)
|
||||
const [playerError, setPlayerError] = useState('')
|
||||
// 「客户端直连解码」模式:宿主机不转码,播放器强制 direct play、隐藏 HLS 切换。
|
||||
const [directOnly, setDirectOnly] = useState(false)
|
||||
|
||||
// 读取宿主机的「直连解码」开关。开启时全程 direct play,不走 HLS。
|
||||
useEffect(() => {
|
||||
systemAPI
|
||||
.info()
|
||||
.then((info) => setDirectOnly(Boolean(info.direct_play_only)))
|
||||
.catch(() => setDirectOnly(false))
|
||||
}, [])
|
||||
|
||||
// Load metadata and pick a default mode.
|
||||
useEffect(() => {
|
||||
@@ -56,15 +45,14 @@ export function PlayerPage() {
|
||||
setMedia(m)
|
||||
const forced = params.get('mode') as Mode | null
|
||||
const auto = pickMode(m)
|
||||
// 直连解码模式下忽略 ?mode=hls 与自动判定,始终 direct play。
|
||||
setMode(directOnly ? 'direct' : (forced ?? auto))
|
||||
setMode(forced ?? auto)
|
||||
setPlayerError('')
|
||||
})
|
||||
subtitlesAPI
|
||||
.list(id)
|
||||
.then((tracks) => setSubs(tracks ?? []))
|
||||
.catch(() => setSubs([]))
|
||||
}, [id, params, directOnly])
|
||||
}, [id, params])
|
||||
|
||||
// Wire up the actual <video> element when we know the mode.
|
||||
useEffect(() => {
|
||||
@@ -173,35 +161,26 @@ export function PlayerPage() {
|
||||
<ArrowLeft size={16} /> 返回
|
||||
</button>
|
||||
|
||||
{directOnly ? (
|
||||
<span
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur"
|
||||
title="宿主机不转码,由客户端本地解码直连"
|
||||
>
|
||||
<Sparkles size={14} /> 客户端直连解码
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||
setMode(next)
|
||||
params.set('mode', next)
|
||||
setParams(params, { replace: true })
|
||||
}}
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
|
||||
title="切换播放模式"
|
||||
>
|
||||
{mode === 'hls' ? (
|
||||
<>
|
||||
<RefreshCw size={14} /> HLS 转码中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={14} /> 直接播放
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||
setMode(next)
|
||||
params.set('mode', next)
|
||||
setParams(params, { replace: true })
|
||||
}}
|
||||
className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/15 bg-black/70 px-4 py-2 text-sm font-medium text-white shadow-xl backdrop-blur transition hover:bg-black/85"
|
||||
title="切换播放模式"
|
||||
>
|
||||
{mode === 'hls' ? (
|
||||
<>
|
||||
<RefreshCw size={14} /> HLS 转码中
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={14} /> 直接播放
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
@@ -216,10 +195,7 @@ export function PlayerPage() {
|
||||
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
|
||||
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
|
||||
if (mode === 'direct') {
|
||||
if (directOnly) {
|
||||
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
|
||||
toast.error('直接播放失败(客户端直连解码模式)')
|
||||
} else if (hlsUnavailable) {
|
||||
if (hlsUnavailable) {
|
||||
setPlayerError('直接播放失败,且 HLS 转码不可用。请检查文件是否存在,或配置本机 ffmpeg 后使用 HLS 转码播放。')
|
||||
toast.error('直接播放失败,HLS 转码不可用')
|
||||
} else {
|
||||
|
||||
@@ -47,18 +47,11 @@ const GROUPS: SettingGroup[] = [
|
||||
{ value: 'ja-JP', label: '日本語' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'playback.direct_only',
|
||||
label: '客户端直连解码(释放宿主机资源)',
|
||||
type: 'toggle',
|
||||
hint: '默认关闭。开启后宿主机不再进行任何 FFmpeg 转码,所有播放交给第三方客户端(Infuse / VLC / Emby 客户端等)或浏览器本地解码直连(direct play / 302 直链),大幅降低宿主机 CPU 占用。若客户端不支持源编码可能无法播放。',
|
||||
defaultValue: 'false',
|
||||
},
|
||||
{
|
||||
key: 'transcode.enabled',
|
||||
label: '启用转码',
|
||||
type: 'toggle',
|
||||
hint: '关闭后所有视频直连播放(「客户端直连解码」开启时本项自动失效)',
|
||||
hint: '关闭后所有视频直连播放',
|
||||
defaultValue: 'true',
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user