From f20c9c3234878ebf0739ba55bc803ff7fcc04426 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:52:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20STRM=20=E7=9B=B4=E8=BF=9E=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E5=90=8E=E5=9B=9E=E9=80=80=20HLS=20=E8=BD=AC=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 不再把 STRM/云盘媒体锁死为直连,解析远端源后允许 ffmpeg 转码播放。 Co-authored-by: Cursor --- internal/handler/playback_extra.go | 4 +- internal/handler/playback_extra_test.go | 6 +- internal/handler/streaming.go | 2 +- internal/service/service_builder.go | 2 + internal/service/stream_file.go | 2 +- internal/service/transcoder.go | 93 ++++++++++++++++++--- internal/service/transcoder_ffmpeg_args.go | 59 ++++++++++++- internal/service/transcoder_ffmpeg_probe.go | 6 +- internal/service/transcoder_test.go | 86 +++++++++++++++++++ web/src/pages/PlayerPage.tsx | 18 ++-- web/src/pages/playerPageModel.ts | 17 ++-- 11 files changed, 253 insertions(+), 42 deletions(-) diff --git a/internal/handler/playback_extra.go b/internal/handler/playback_extra.go index 94ae453..1ecdb2b 100644 --- a/internal/handler/playback_extra.go +++ b/internal/handler/playback_extra.go @@ -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{ diff --git a/internal/handler/playback_extra_test.go b/internal/handler/playback_extra_test.go index 449ba0f..3bea2fe 100644 --- a/internal/handler/playback_extra_test.go +++ b/internal/handler/playback_extra_test.go @@ -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) } } diff --git a/internal/handler/streaming.go b/internal/handler/streaming.go index 9137906..468f86f 100644 --- a/internal/handler/streaming.go +++ b/internal/handler/streaming.go @@ -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 diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index f953d63..9e0fae9 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -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) { diff --git a/internal/service/stream_file.go b/internal/service/stream_file.go index e720054..27390c5 100644 --- a/internal/service/stream_file.go +++ b/internal/service/stream_file.go @@ -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 diff --git a/internal/service/transcoder.go b/internal/service/transcoder.go index 780f85d..33d604a 100644 --- a/internal/service/transcoder.go +++ b/internal/service/transcoder.go @@ -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") +} diff --git a/internal/service/transcoder_ffmpeg_args.go b/internal/service/transcoder_ffmpeg_args.go index 318b3d7..ad45066 100644 --- a/internal/service/transcoder_ffmpeg_args.go +++ b/internal/service/transcoder_ffmpeg_args.go @@ -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 + } +} diff --git a/internal/service/transcoder_ffmpeg_probe.go b/internal/service/transcoder_ffmpeg_probe.go index 0901cd7..e165d7c 100644 --- a/internal/service/transcoder_ffmpeg_probe.go +++ b/internal/service/transcoder_ffmpeg_probe.go @@ -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, diff --git a/internal/service/transcoder_test.go b/internal/service/transcoder_test.go index fea28d9..501d42f 100644 --- a/internal/service/transcoder_test.go +++ b/internal/service/transcoder_test.go @@ -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) + } +} diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index 89a1384..c8bf13e 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -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 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() { // 浏览器对