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