diff --git a/internal/handler/system_meta.go b/internal/handler/system_meta.go index a488b14..9f97157 100644 --- a/internal/handler/system_meta.go +++ b/internal/handler/system_meta.go @@ -24,14 +24,21 @@ 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, + "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, }) } } diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index b1800bd..b1e6215 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -36,6 +36,11 @@ 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 @@ -644,7 +649,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)}, + "MediaSources": []map[string]any{e.mediaSource(m, true, false)}, } } @@ -1149,11 +1154,24 @@ 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)}, + "MediaSources": []map[string]any{e.mediaSource(m, false, e.directPlayOnly(ctx))}, "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 @@ -1180,7 +1198,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 bool) map[string]any { +func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) map[string]any { src := map[string]any{ "Id": m.ID, "Name": m.Title, @@ -1190,7 +1208,7 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded bool) map[string]an "Protocol": "Http", "Type": "Default", "IsRemote": false, - "SupportsTranscoding": true, + "SupportsTranscoding": !directOnly, "SupportsDirectStream": true, "SupportsDirectPlay": true, "SupportsProbing": true, @@ -1199,7 +1217,11 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded bool) map[string]an } if !asEmbedded { src["DirectStreamUrl"] = "/Videos/" + m.ID + "/stream" - src["TranscodingUrl"] = "/Videos/" + m.ID + "/stream" + // 直连解码模式下不下发 TranscodingUrl,迫使客户端本地解码直连, + // 宿主机不参与转码。 + if !directOnly { + src["TranscodingUrl"] = "/Videos/" + m.ID + "/stream" + } } if strings.TrimSpace(m.STRMURL) != "" { // STRM 重定向:客户端直接拉远端,跳过我们这一层。 diff --git a/internal/service/emby_compat_test.go b/internal/service/emby_compat_test.go index bb4cad8..8676119 100644 --- a/internal/service/emby_compat_test.go +++ b/internal/service/emby_compat_test.go @@ -202,6 +202,50 @@ 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{}) diff --git a/internal/service/runtime_settings.go b/internal/service/runtime_settings.go index 8ffea2a..fb77cca 100644 --- a/internal/service/runtime_settings.go +++ b/internal/service/runtime_settings.go @@ -75,6 +75,12 @@ 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", "启用", "开启": diff --git a/internal/service/stream.go b/internal/service/stream.go index 1596511..296e866 100644 --- a/internal/service/stream.go +++ b/internal/service/stream.go @@ -54,6 +54,20 @@ 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. // @@ -91,6 +105,11 @@ 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 } diff --git a/web/src/api/system.ts b/web/src/api/system.ts index 14e1566..e6506dc 100644 --- a/web/src/api/system.ts +++ b/web/src/api/system.ts @@ -13,6 +13,7 @@ export const systemAPI = { arch: string data_dir: string cache_dir: string + direct_play_only?: boolean }>('/system/info') .then((r) => r.data), diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index 563b09a..352ea3f 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -8,6 +8,7 @@ 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' @@ -37,6 +38,16 @@ export function PlayerPage() { const [subs, setSubs] = useState([]) 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(() => { @@ -45,14 +56,15 @@ export function PlayerPage() { setMedia(m) const forced = params.get('mode') as Mode | null const auto = pickMode(m) - setMode(forced ?? auto) + // 直连解码模式下忽略 ?mode=hls 与自动判定,始终 direct play。 + setMode(directOnly ? 'direct' : (forced ?? auto)) setPlayerError('') }) subtitlesAPI .list(id) .then((tracks) => setSubs(tracks ?? [])) .catch(() => setSubs([])) - }, [id, params]) + }, [id, params, directOnly]) // Wire up the actual