mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
fix(web): STRM 直连失败后回退 HLS 转码
不再把 STRM/云盘媒体锁死为直连,解析远端源后允许 ffmpeg 转码播放。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -45,8 +45,8 @@ func playbackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
token := externalPlaybackToken(c, svc, m.ID, m.DurationSec)
|
||||
profileQuery := externalProfileQuery(c)
|
||||
hlsURL := "/api/hls/" + m.ID + "/index.m3u8?token=" + url.QueryEscape(token) + profileQuery
|
||||
if service.IsEmbyRemoteID(m.ID) || service.IsStrmMediaRow(m) {
|
||||
// Emby 远程挂载与 STRM 媒体一样,默认直连播放,不提供转码地址
|
||||
if service.IsEmbyRemoteID(m.ID) {
|
||||
// 远程 Emby 挂载没有本地文件,不能提供转码地址。STRM 默认直连,直连失败时可走 HLS。
|
||||
hlsURL = ""
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
|
||||
@@ -413,7 +413,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
|
||||
return router, svc, cfg.Secrets.JWTSecret
|
||||
}
|
||||
|
||||
func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
|
||||
func TestPlaybackInfoForSTRMMediaIncludesHLS(t *testing.T) {
|
||||
router, _, secret := newPlaybackScopeTestRouter(t)
|
||||
loginToken := signedTestToken(t, secret)
|
||||
|
||||
@@ -435,8 +435,8 @@ func TestPlaybackInfoForSTRMMediaDisablesHLS(t *testing.T) {
|
||||
if payload.StreamURL == "" {
|
||||
t.Fatalf("expected non-empty stream_url")
|
||||
}
|
||||
if payload.HlsURL != "" {
|
||||
t.Fatalf("expected empty hls_url for STRM media, got %q", payload.HlsURL)
|
||||
if payload.HlsURL == "" || !strings.Contains(payload.HlsURL, "/api/hls/media-1/") {
|
||||
t.Fatalf("expected strm hls_url, got %q", payload.HlsURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func hlsPlaylistHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
// 远程 Emby 挂载媒体与 STRM 一样,默认直连播放,不进行转码。
|
||||
// 远程 Emby 挂载没有本地文件,不能转码。STRM 允许直连失败后走 HLS。
|
||||
if svc.EmbyRemote != nil && service.IsEmbyRemoteID(id) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "transcode disabled"})
|
||||
return
|
||||
|
||||
@@ -121,6 +121,8 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
b.c.FFTools = NewFFmpegToolsService(b.cfg, b.log, b.repos)
|
||||
// 弹幕 hash 识别需要把 strm 指向解析成可拉取的直链/本地路径。
|
||||
b.c.Danmaku.SetStrmResolver(b.c.Strm.ResolvePlay)
|
||||
// STRM 直连失败后的 HLS 转码:把 .strm 解析成 ffmpeg 可读取的本地路径或 HTTP 直链。
|
||||
b.c.Transcoder.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
|
||||
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
|
||||
if b.c.EmbyRemote != nil {
|
||||
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
|
||||
|
||||
@@ -87,7 +87,7 @@ func playableSTRMTarget(ctx context.Context, repo *repository.Container, raw str
|
||||
}
|
||||
|
||||
// IsStrmMediaRow 判断媒体行是否为 .strm(远程直链)媒体:STRMURL 非空、
|
||||
// container=strm 或路径以 .strm 结尾。strm 媒体只能直连播放,禁止转码。
|
||||
// container=strm 或路径以 .strm 结尾。网页播放默认直连,失败后可转码。
|
||||
func IsStrmMediaRow(m *model.Media) bool {
|
||||
if m == nil {
|
||||
return false
|
||||
|
||||
@@ -25,8 +25,10 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -34,6 +36,7 @@ import (
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
@@ -44,8 +47,9 @@ type TranscoderService struct {
|
||||
repo *repository.Container
|
||||
hub *Hub
|
||||
|
||||
mu sync.Mutex
|
||||
jobs map[string]*hlsJob
|
||||
mu sync.Mutex
|
||||
jobs map[string]*hlsJob
|
||||
strmResolve func(ctx context.Context, raw string) (*StrmPlayResult, error)
|
||||
}
|
||||
|
||||
// hlsJob holds the live state of one ffmpeg run.
|
||||
@@ -102,14 +106,17 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
||||
if m == nil {
|
||||
return "", ErrMediaNotFound
|
||||
}
|
||||
// .strm 媒体(STRMURL 或 container=strm / *.strm 路径)的内容是远程
|
||||
// 直链文本,ffmpeg 无法读取,转码必然失败且白白消耗资源。直接拒绝
|
||||
// 转码,迫使播放器走 /api/stream 302 直连播放。
|
||||
if isStrmMediaRow(m) {
|
||||
return "", ErrTranscodeDisabled
|
||||
t.mu.Lock()
|
||||
if _, ok := t.jobs[mediaID]; ok {
|
||||
t.touchJobLocked(mediaID)
|
||||
t.mu.Unlock()
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
if _, err := os.Stat(m.Path); err != nil {
|
||||
return "", ErrMediaNotFound
|
||||
t.mu.Unlock()
|
||||
|
||||
input, err := t.resolveTranscodeInput(ctx, m)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := t.resolveFFmpegPath(); err != nil {
|
||||
return "", err
|
||||
@@ -145,6 +152,72 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
||||
t.mu.Unlock()
|
||||
|
||||
helper.Go(t.log, "transcoder.monitorIdle", func() { t.monitorIdle(jobCtx, job) })
|
||||
helper.Go(t.log, "transcoder.ffmpeg", func() { t.runFFmpeg(jobCtx, job, m.Path) })
|
||||
helper.Go(t.log, "transcoder.ffmpeg", func() { t.runFFmpeg(jobCtx, job, input) })
|
||||
return t.PlaylistPath(mediaID), nil
|
||||
}
|
||||
|
||||
// SetStrmPlayTargetResolver wires STRM URL resolution so ffmpeg can transcode
|
||||
// remote .strm media (HTTP 直链 or local source path) after direct play fails.
|
||||
func (t *TranscoderService) SetStrmPlayTargetResolver(resolve func(ctx context.Context, raw string) (*StrmPlayResult, error)) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
t.strmResolve = resolve
|
||||
}
|
||||
|
||||
func (t *TranscoderService) resolveTranscodeInput(ctx context.Context, m *model.Media) (transcodeInput, error) {
|
||||
if m == nil {
|
||||
return transcodeInput{}, ErrMediaNotFound
|
||||
}
|
||||
if !isStrmMediaRow(m) {
|
||||
if _, err := os.Stat(m.Path); err != nil {
|
||||
return transcodeInput{}, ErrMediaNotFound
|
||||
}
|
||||
return transcodeInput{Source: m.Path}, nil
|
||||
}
|
||||
raw := strings.TrimSpace(m.STRMURL)
|
||||
if raw == "" && strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") {
|
||||
parsed, err := readLocalSTRMTarget(m.Path)
|
||||
if err != nil || strings.TrimSpace(parsed) == "" {
|
||||
return transcodeInput{}, fmt.Errorf("strm play target missing")
|
||||
}
|
||||
raw = parsed
|
||||
}
|
||||
if raw == "" {
|
||||
return transcodeInput{}, fmt.Errorf("strm play target missing")
|
||||
}
|
||||
if t != nil && t.strmResolve != nil {
|
||||
src, err := t.strmResolve(ctx, raw)
|
||||
if err != nil {
|
||||
return transcodeInput{}, err
|
||||
}
|
||||
return transcodeInputFromPlayResult(src)
|
||||
}
|
||||
if isHTTPPlaybackTarget(raw) {
|
||||
return transcodeInput{Source: raw}, nil
|
||||
}
|
||||
return transcodeInput{}, fmt.Errorf("strm transcode source unavailable")
|
||||
}
|
||||
|
||||
func transcodeInputFromPlayResult(src *StrmPlayResult) (transcodeInput, error) {
|
||||
if src == nil {
|
||||
return transcodeInput{}, fmt.Errorf("strm transcode source unavailable")
|
||||
}
|
||||
if path := strings.TrimSpace(src.LocalPath); path != "" {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return transcodeInput{}, ErrMediaNotFound
|
||||
}
|
||||
return transcodeInput{Source: path}, nil
|
||||
}
|
||||
if url := strings.TrimSpace(src.RedirectURL); url != "" {
|
||||
in := transcodeInput{Source: url}
|
||||
if src.Link != nil {
|
||||
in.Headers = src.Link.Headers
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
if src.Link != nil && strings.TrimSpace(src.Link.URL) != "" {
|
||||
return transcodeInput{Source: src.Link.URL, Headers: src.Link.Headers}, nil
|
||||
}
|
||||
return transcodeInput{}, fmt.Errorf("strm transcode source unavailable")
|
||||
}
|
||||
|
||||
@@ -2,11 +2,19 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
)
|
||||
|
||||
type transcodeInput struct {
|
||||
Source string
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
type ffmpegArgSettings struct {
|
||||
encoder string
|
||||
bitrate string
|
||||
@@ -31,11 +39,15 @@ type ffmpegVideoPlan struct {
|
||||
// encoder. The function is package-level so the unit test can pin its
|
||||
// behaviour without spawning a real ffmpeg process.
|
||||
func buildFFmpegArgs(cfg *config.Config, source, playlist, segments string) []string {
|
||||
return buildFFmpegArgsForInput(cfg, transcodeInput{Source: source}, playlist, segments)
|
||||
}
|
||||
|
||||
func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist, segments string) []string {
|
||||
settings := ffmpegArgSettingsFromConfig(cfg)
|
||||
video := ffmpegVideoPlanForSettings(settings)
|
||||
|
||||
args := baseFFmpegArgs(video.preInput, settings.realtime)
|
||||
args = appendInputAndVideoArgs(args, source, settings, video)
|
||||
args = appendInputAndVideoArgs(args, input, settings, video)
|
||||
args = appendOutputHLSArgs(args, settings, segments, playlist)
|
||||
return args
|
||||
}
|
||||
@@ -111,8 +123,9 @@ func baseFFmpegArgs(preInput string, realtime bool) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
func appendInputAndVideoArgs(args []string, source string, settings ffmpegArgSettings, video ffmpegVideoPlan) []string {
|
||||
args = append(args, "-i", source, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec)
|
||||
func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpegArgSettings, video ffmpegVideoPlan) []string {
|
||||
args = append(args, ffmpegHTTPInputArgs(input)...)
|
||||
args = append(args, "-i", input.Source, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec)
|
||||
if settings.threads > 0 && video.codec == "libx264" {
|
||||
args = append(args, "-threads", strconv.Itoa(settings.threads))
|
||||
}
|
||||
@@ -164,3 +177,43 @@ func splitNonEmptyArgs(s string) []string {
|
||||
flush()
|
||||
return out
|
||||
}
|
||||
|
||||
func ffmpegHTTPInputArgs(input transcodeInput) []string {
|
||||
if !isHTTPSource(input.Source) {
|
||||
return nil
|
||||
}
|
||||
args := []string{"-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "2"}
|
||||
if len(input.Headers) == 0 {
|
||||
return args
|
||||
}
|
||||
keys := make([]string, 0, len(input.Headers))
|
||||
for key := range input.Headers {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
lines := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
value := strings.TrimSpace(input.Headers[key])
|
||||
if strings.TrimSpace(key) == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, key+": "+value)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return args
|
||||
}
|
||||
return append(args, "-headers", strings.Join(lines, "\r\n")+"\r\n")
|
||||
}
|
||||
|
||||
func isHTTPSource(source string) bool {
|
||||
u, err := url.Parse(strings.TrimSpace(source))
|
||||
if err != nil || u == nil {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(u.Scheme)) {
|
||||
case "http", "https":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source string) {
|
||||
func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, input transcodeInput) {
|
||||
bin, err := t.resolveFFmpegPath()
|
||||
if err != nil {
|
||||
t.log.Warn("ffmpeg unavailable", zap.String("media_id", job.mediaID), zap.Error(err))
|
||||
@@ -31,7 +31,7 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source s
|
||||
playlist := filepath.Join(job.outputDir, "index.m3u8")
|
||||
segments := filepath.Join(job.outputDir, "seg_%05d.ts")
|
||||
|
||||
args := buildFFmpegArgs(t.cfg, source, playlist, segments)
|
||||
args := buildFFmpegArgsForInput(t.cfg, input, playlist, segments)
|
||||
|
||||
cmd := exec.CommandContext(ctx, bin, args...) // #nosec G204 -- bin is resolved by resolveFFmpegPath and args are passed without a shell.
|
||||
cmd.Stderr = os.Stderr
|
||||
@@ -39,7 +39,7 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, source s
|
||||
t.log.Info("transcode started",
|
||||
zap.String("media_id", job.mediaID),
|
||||
zap.String("encoder", job.encoder),
|
||||
zap.String("source", source),
|
||||
zap.String("source", input.Source),
|
||||
)
|
||||
t.hub.Publish("transcode", map[string]any{
|
||||
"media_id": job.mediaID,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/cloud"
|
||||
)
|
||||
|
||||
func TestBuildFFmpegArgs(t *testing.T) {
|
||||
@@ -107,3 +110,86 @@ func TestHasFFmpegListEntry(t *testing.T) {
|
||||
t.Fatal("must match whole ffmpeg list entries only")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTranscodeInputHTTPSTRM(t *testing.T) {
|
||||
svc := &TranscoderService{}
|
||||
got, err := svc.resolveTranscodeInput(context.Background(), &model.Media{
|
||||
Container: "strm",
|
||||
STRMURL: "https://cdn.example.com/a.wmv",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Source != "https://cdn.example.com/a.wmv" {
|
||||
t.Fatalf("source = %q", got.Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTranscodeInputUsesResolver(t *testing.T) {
|
||||
svc := &TranscoderService{}
|
||||
svc.SetStrmPlayTargetResolver(func(_ context.Context, raw string) (*StrmPlayResult, error) {
|
||||
if raw != "/api/strm/play/cloud115/a.wmv?acct=1&pickcode=x" {
|
||||
t.Fatalf("raw = %q", raw)
|
||||
}
|
||||
return &StrmPlayResult{
|
||||
RedirectURL: "https://cdn.example.com/a.wmv",
|
||||
Link: &cloud.DirectLink{
|
||||
URL: "https://cdn.example.com/a.wmv",
|
||||
Headers: map[string]string{"User-Agent": "Mozilla/5.0"},
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
got, err := svc.resolveTranscodeInput(context.Background(), &model.Media{
|
||||
Container: "strm",
|
||||
STRMURL: "/api/strm/play/cloud115/a.wmv?acct=1&pickcode=x",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Source != "https://cdn.example.com/a.wmv" {
|
||||
t.Fatalf("source = %q", got.Source)
|
||||
}
|
||||
if got.Headers["User-Agent"] != "Mozilla/5.0" {
|
||||
t.Fatalf("headers = %#v", got.Headers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTranscodeInputRejectsUnresolvedRelativeSTRM(t *testing.T) {
|
||||
svc := &TranscoderService{}
|
||||
_, err := svc.resolveTranscodeInput(context.Background(), &model.Media{
|
||||
Container: "strm",
|
||||
STRMURL: "/api/strm/play/cloud115/a.wmv?acct=1&pickcode=x",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unresolved relative strm to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsHTTPInputReconnect(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Transcoder.MaxHeight = 720
|
||||
cfg.Transcoder.SegmentSeconds = 4
|
||||
args := buildFFmpegArgsForInput(cfg, transcodeInput{
|
||||
Source: "https://cdn.example.com/a.wmv",
|
||||
Headers: map[string]string{"User-Agent": "MeBox", "Referer": "https://cdn.example.com/"},
|
||||
}, "/o/x.m3u8", "/o/seg_%05d.ts")
|
||||
joined := strings.Join(args, " ")
|
||||
if !strings.Contains(joined, "-reconnect") || !strings.Contains(joined, "-headers") {
|
||||
t.Fatalf("expected http reconnect/headers, got: %s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "User-Agent: MeBox") || !strings.Contains(joined, "Referer: https://cdn.example.com/") {
|
||||
t.Fatalf("expected request headers, got: %s", joined)
|
||||
}
|
||||
idxI, idxH := -1, -1
|
||||
for i, arg := range args {
|
||||
if arg == "-i" && idxI < 0 {
|
||||
idxI = i
|
||||
}
|
||||
if arg == "-headers" {
|
||||
idxH = i
|
||||
}
|
||||
}
|
||||
if idxI < 0 || idxH < 0 || idxH > idxI {
|
||||
t.Fatalf("http flags must come before -i, args=%v", args)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ import { mediaVersionsOf } from '../utils/mediaVersion'
|
||||
//
|
||||
// We pick a sensible default based on the source codec: H.264 + AAC in
|
||||
// MP4 / WebM containers play directly; everything else (HEVC, MKV, AV1,
|
||||
// AC3 audio, …) gets routed through ffmpeg → HLS.
|
||||
// AC3 audio, …) gets routed through ffmpeg → HLS. STRM / 云盘直链默认直连,
|
||||
// 浏览器播不了时再切 HLS。远程 Emby 挂载只能直连。
|
||||
//
|
||||
// External subtitles next to the source file are auto-discovered and
|
||||
// attached as <track> elements.
|
||||
@@ -208,7 +209,7 @@ export function PlayerPage() {
|
||||
setMedia(m)
|
||||
const isDirect = isDirectStreamMedia(m)
|
||||
const auto = pickPlayerMode(m)
|
||||
// 直连解码模式以及 STRM / Emby 挂载等直连媒体,忽略 ?mode=hls,始终 direct play。
|
||||
// 直连解码模式以及远程 Emby 挂载忽略 ?mode=hls。STRM 默认直连,但允许手动/失败后切 HLS。
|
||||
setMode(directOnly || isDirect ? 'direct' : (modeParam ?? auto))
|
||||
setPlayerError('')
|
||||
setLoadError('')
|
||||
@@ -539,12 +540,9 @@ export function PlayerPage() {
|
||||
// 浏览器对 <video src> 的错误描述非常有限,把详细原因
|
||||
// 转给开发者控制台 + 一条 toast;常见原因是 codec 不支持。
|
||||
if (mode === 'direct') {
|
||||
if (isRemoteEmbyID(media?.id)) {
|
||||
if (isRemoteEmbyID(media?.id) || isDirectStreamMedia(media)) {
|
||||
setPlayerError('直接播放失败。该媒体为远程 Emby 挂载直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器(如 PotPlayer / VLC / IINA)播放。')
|
||||
toast.error('直接播放失败,建议使用外部播放器')
|
||||
} else if (isDirectStreamMedia(media)) {
|
||||
setPlayerError('直接播放失败。该媒体为 STRM 远程直连播放(不进行转码);当前浏览器可能不支持该视频编码或音频格式,建议使用外部播放器播放。')
|
||||
toast.error('直接播放失败,建议使用外部播放器')
|
||||
} else if (directOnly) {
|
||||
setPlayerError('直接播放失败。当前为「客户端直连解码」模式,宿主机不转码;请使用支持该编码/封装的播放器(如 Infuse / VLC / Emby 客户端)播放,或关闭直连解码模式。')
|
||||
toast.error('直接播放失败(客户端直连解码模式)')
|
||||
@@ -575,13 +573,7 @@ export function PlayerPage() {
|
||||
<PlayerTopBar
|
||||
directOnly={directOnly}
|
||||
isDirectStream={isDirectStream}
|
||||
directStreamLabel={
|
||||
isRemoteEmbyID(media?.id)
|
||||
? 'Emby 直连播放'
|
||||
: isDirectStream
|
||||
? 'STRM 直连播放'
|
||||
: undefined
|
||||
}
|
||||
directStreamLabel={isRemoteEmbyID(media?.id) ? 'Emby 直连播放' : undefined}
|
||||
mode={mode}
|
||||
onBack={goBack}
|
||||
onToggleMode={toggleMode}
|
||||
|
||||
@@ -8,13 +8,18 @@ const directVideoCodecs = ['h264', 'avc', 'avc1']
|
||||
const directAudioCodecs = ['aac', 'mp3', 'opus']
|
||||
|
||||
/**
|
||||
* 判断媒体是否为远程直链或挂载直连流媒体(STRM 或 Emby 远程挂载)。
|
||||
* 这类媒体服务端 302 重定向到直链或进行原生流中继,本地不具备原始文件,
|
||||
* 无法也不应该进行 ffmpeg 转码,恒走直连播放。
|
||||
* 远程 Emby 挂载:本地没有原始文件,网页端只能直连,不能转码。
|
||||
*/
|
||||
export function isDirectStreamMedia(media?: Media | null): boolean {
|
||||
if (!media) return false
|
||||
if (isRemoteEmbyID(media.id)) return true
|
||||
return isRemoteEmbyID(media.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* STRM / 云盘直链:默认仍走直连;浏览器解不了时再回退 HLS 转码。
|
||||
*/
|
||||
export function isStrmMedia(media?: Media | null): boolean {
|
||||
if (!media) return false
|
||||
const container = (media.container ?? '').toLowerCase()
|
||||
return container.includes('strm') || String(media.strm_url ?? '').trim() !== ''
|
||||
}
|
||||
@@ -24,8 +29,8 @@ export function pickPlayerMode(media: Media): PlayerMode {
|
||||
}
|
||||
|
||||
export function needsTranscodeForBrowser(media: Media): boolean {
|
||||
// Emby 远程挂载与 .strm 媒体一样,均为直连流,无法进行本地转码,恒走 direct play。
|
||||
if (isDirectStreamMedia(media)) return false
|
||||
// Emby 远程挂载无法本地转码。STRM 先直连,失败后再由播放器切 HLS。
|
||||
if (isDirectStreamMedia(media) || isStrmMedia(media)) return false
|
||||
|
||||
const container = (media.container ?? '').toLowerCase()
|
||||
const videoCodec = (media.video_codec ?? '').toLowerCase()
|
||||
|
||||
Reference in New Issue
Block a user