Revert "feat(playback): client direct-play decode mode (release host transcoding)"

This reverts commit 8fd2ab4b51.
This commit is contained in:
Shuke
2026-05-30 21:03:25 +08:00
parent 6a9096d3aa
commit 48aa08fc94
8 changed files with 36 additions and 166 deletions
+7 -14
View File
@@ -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,
})
}
}
+5 -27
View File
@@ -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 重定向:客户端直接拉远端,跳过我们这一层。
-44
View File
@@ -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{})
-6
View File
@@ -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", "启用", "开启":
-19
View File
@@ -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
}
-1
View File
@@ -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),
+23 -47
View File
@@ -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 {
+1 -8
View File
@@ -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',
},
{