Compare commits

...

6 Commits

Author SHA1 Message Date
truewhile ad427fc0d2 bug处理 2026-09-08 00:10:25 +08:00
truewhile c489374b61 bug处理 2026-09-07 23:45:59 +08:00
truewhile 5a47e6f52d bug处理 2026-09-07 23:21:24 +08:00
truewhile c4b8114115 优化转码逻辑 2026-09-07 23:10:48 +08:00
truewhile 0e592105c4 fix(web): STRM 直连失败后回退 HLS 转码
fix(web): STRM 直连失败后回退 HLS 转码
2026-09-07 22:56:29 +08:00
truewhile f20c9c3234 fix(web): STRM 直连失败后回退 HLS 转码
不再把 STRM/云盘媒体锁死为直连,解析远端源后允许 ffmpeg 转码播放。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 22:52:54 +08:00
19 changed files with 897 additions and 111 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"mcpServers": {
"ssh": {
"command": "cmd.exe",
"args": [
"/c",
"npx",
"-y",
"@aiondadotcom/mcp-ssh"
],
"env": {
"ProgramData": "C:\\ProgramData"
}
}
}
}
+2 -2
View File
@@ -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{
+3 -3
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
+3
View File
@@ -121,6 +121,9 @@ 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)
b.c.Transcoder.SetProbe(b.c.FFprobe)
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
if b.c.EmbyRemote != nil {
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
+1 -1
View File
@@ -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
+71 -3
View File
@@ -6,23 +6,35 @@ import (
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// ServeHLSPlaylist makes sure a transcode is running and writes the m3u8.
// We block (with a 30s timeout) until the playlist file shows up.
//
// Optional query `start` (seconds) restarts ffmpeg from that source offset so
// the web player can scrub the full timeline without waiting for a full
// head-to-tail transcode.
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 {
startSec := parseHLSStartSec(r)
seekGen := parseHLSSeekGen(r)
if _, err := s.transcoder.EnsureJobFrom(r.Context(), mediaID, startSec, seekGen); err != nil {
return err
}
s.transcoder.TouchJob(mediaID)
if !s.transcoder.WaitReady(r.Context(), mediaID, 30*time.Second) {
readyTimeout := 45 * time.Second
if startSec > 0.05 {
// Mid-file HTTP seeks (esp. WMV) need longer before the first segment appears.
readyTimeout = 120 * time.Second
}
if !s.transcoder.WaitReady(r.Context(), mediaID, readyTimeout) {
return errors.New("hls playlist not ready")
}
playlist := s.transcoder.PlaylistPath(mediaID)
@@ -48,10 +60,46 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
return nil
}
func parseHLSStartSec(r *http.Request) float64 {
if r == nil {
return 0
}
raw := strings.TrimSpace(r.URL.Query().Get("start"))
if raw == "" {
return 0
}
v, err := strconv.ParseFloat(raw, 64)
if err != nil || v < 0 {
return 0
}
return v
}
func parseHLSSeekGen(r *http.Request) int64 {
if r == nil {
return 0
}
raw := strings.TrimSpace(r.URL.Query().Get("_seek"))
if raw == "" {
return 0
}
v, err := strconv.ParseInt(raw, 10, 64)
if err != nil || v < 0 {
return 0
}
return v
}
func appendQueryToHLSSegments(playlist, rawQuery string) string {
if strings.TrimSpace(rawQuery) == "" {
return playlist
}
// Segment fetches only need auth/profile tokens; drop start= so a seek
// restart does not keep forcing EnsureJobFrom on every .ts hit.
q := filterHLSSegmentQuery(rawQuery)
if q == "" {
return playlist
}
lines := strings.SplitAfter(playlist, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
@@ -65,12 +113,32 @@ func appendQueryToHLSSegments(playlist, rawQuery string) string {
} else if strings.HasSuffix(line, "\n") {
lineEnding = "\n"
}
lines[i] = strings.TrimRight(line, "\r\n") + "?" + rawQuery + lineEnding
lines[i] = strings.TrimRight(line, "\r\n") + "?" + q + lineEnding
}
}
return strings.Join(lines, "")
}
func filterHLSSegmentQuery(rawQuery string) string {
parts := strings.Split(rawQuery, "&")
kept := make([]string, 0, len(parts))
for _, part := range parts {
if part == "" {
continue
}
key := part
if i := strings.IndexByte(part, '='); i >= 0 {
key = part[:i]
}
switch strings.ToLower(key) {
case "start", "_seek":
continue
}
kept = append(kept, part)
}
return strings.Join(kept, "&")
}
// ServeHLSSegment writes a single .ts segment from the on-disk cache.
func (s *StreamService) ServeHLSSegment(w http.ResponseWriter, r *http.Request, mediaID, segment string) error {
s.transcoder.TouchJob(mediaID)
+267 -22
View File
@@ -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,13 @@ type TranscoderService struct {
repo *repository.Container
hub *Hub
mu sync.Mutex
jobs map[string]*hlsJob
mu sync.Mutex
jobs map[string]*hlsJob
// startGates serializes EnsureJobFrom / StopJob per media so concurrent
// playlist hits cannot spawn multiple ffmpeg writers into one HLS dir.
startGates sync.Map // mediaID -> *sync.Mutex
strmResolve func(ctx context.Context, raw string) (*StrmPlayResult, error)
probe *FFprobeService
}
// hlsJob holds the live state of one ffmpeg run.
@@ -57,6 +65,14 @@ type hlsJob struct {
lastAccess time.Time
playlistOK bool
encoder string
// startSec is the source seek offset fed to ffmpeg (-ss). The HLS
// playlist itself always starts at t=0 for that session.
startSec float64
// seekGen is the client `_seek` token for this job. Newer gens win;
// older/missing gens must not cancel a mid-file restart.
seekGen int64
// done is closed when the ffmpeg goroutine fully exits (after process death).
done chan struct{}
}
var (
@@ -88,13 +104,29 @@ func (t *TranscoderService) PlaylistPath(mediaID string) string {
return filepath.Join(t.HLSDir(mediaID), "index.m3u8")
}
// EnsureJob makes sure a transcode is running for mediaID. The function is
// non-blocking: it returns the playlist path immediately. The caller is
// expected to poll until WaitReady reports true.
// EnsureJob makes sure a transcode is running for mediaID from the start of
// the source. Prefer EnsureJobFrom when the player seeks into the middle.
func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (string, error) {
return t.EnsureJobFrom(ctx, mediaID, 0, 0)
}
// EnsureJobFrom starts (or reuses) an HLS job that seeks the source to
// startSec before encoding. Reusing only happens when an active job already
// matches that offset; otherwise the previous job is cancelled and the HLS
// cache dir is wiped so the player can jump without waiting for a full
// head-to-tail transcode.
//
// seekGen is the client `_seek` query (unix ms). A newer gen replaces an older
// job; an older or missing gen must not clobber a mid-file restart — hls.js
// in-flight playlist refreshes from a destroyed player commonly arrive as
// start=0 right after a scrub and would otherwise reset playback to the head.
func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, startSec float64, seekGen int64) (string, error) {
if !t.cfg.Transcoder.Enabled {
return "", ErrTranscodeDisabled
}
if startSec < 0 {
startSec = 0
}
m, err := t.repo.Media.FindByID(ctx, mediaID)
if err != nil {
return "", err
@@ -102,36 +134,69 @@ 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
gate := t.mediaStartGate(mediaID)
gate.Lock()
defer gate.Unlock()
t.mu.Lock()
if existing, ok := t.jobs[mediaID]; ok {
if sameHLSStart(existing.startSec, startSec) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
}
if !shouldReplaceHLSJob(existing, startSec, seekGen) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
}
prev := t.detachJobLocked(mediaID)
t.mu.Unlock()
waitJobExit(prev, 12*time.Second)
} else {
t.mu.Unlock()
}
if _, err := os.Stat(m.Path); err != nil {
return "", ErrMediaNotFound
input, err := t.resolveTranscodeInput(ctx, m)
if err != nil {
return "", err
}
input.StartSec = startSec
t.maybeFillDuration(ctx, m, input)
if _, err := t.resolveFFmpegPath(); err != nil {
return "", err
}
outDir := t.HLSDir(mediaID)
// Wipe prior segments so a mid-file restart cannot serve stale early chunks.
// Only safe after the previous ffmpeg has exited (waited above / via gate).
if err := resetHLSDir(outDir); err != nil {
return "", err
}
t.mu.Lock()
if _, ok := t.jobs[mediaID]; ok {
t.touchJobLocked(mediaID)
if existing, ok := t.jobs[mediaID]; ok {
if sameHLSStart(existing.startSec, startSec) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
}
if !shouldReplaceHLSJob(existing, startSec, seekGen) {
t.touchJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
}
prev := t.detachJobLocked(mediaID)
t.mu.Unlock()
return t.PlaylistPath(mediaID), nil
waitJobExit(prev, 12*time.Second)
t.mu.Lock()
}
if max := t.maxConcurrent(); max > 0 && len(t.jobs) >= max {
t.mu.Unlock()
return "", ErrTranscodeBusy
}
outDir := t.HLSDir(mediaID)
if err := os.MkdirAll(outDir, 0o750); err != nil {
t.mu.Unlock()
return "", err
}
jobCtx, cancel := context.WithCancel(context.Background())
job := &hlsJob{
mediaID: mediaID,
@@ -140,11 +205,191 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
startedAt: time.Now(),
lastAccess: time.Now(),
encoder: t.effectiveEncoder(),
startSec: startSec,
seekGen: seekGen,
done: make(chan struct{}),
}
t.jobs[mediaID] = job
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() {
defer close(job.done)
t.runFFmpeg(jobCtx, job, input)
})
return t.PlaylistPath(mediaID), nil
}
// shouldReplaceHLSJob reports whether an incoming playlist request may cancel
// the running job. Stale hls.js refreshes (older/missing `_seek`) must not win.
func shouldReplaceHLSJob(existing *hlsJob, startSec float64, seekGen int64) bool {
if existing == nil {
return true
}
if sameHLSStart(existing.startSec, startSec) {
return false
}
if seekGen > 0 && existing.seekGen > 0 && seekGen < existing.seekGen {
return false
}
// Untagged request while a seek-tagged job is active: treat as stale.
if seekGen == 0 && existing.seekGen > 0 {
return false
}
return true
}
func (t *TranscoderService) mediaStartGate(mediaID string) *sync.Mutex {
v, _ := t.startGates.LoadOrStore(mediaID, &sync.Mutex{})
return v.(*sync.Mutex)
}
// detachJobLocked cancels and removes a job from the map without waiting.
// Caller must hold t.mu and must waitJobExit afterwards before wiping the HLS dir.
func (t *TranscoderService) detachJobLocked(mediaID string) *hlsJob {
j, ok := t.jobs[mediaID]
if !ok {
return nil
}
j.cancel()
delete(t.jobs, mediaID)
return j
}
func waitJobExit(job *hlsJob, timeout time.Duration) {
if job == nil || job.done == nil {
return
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-job.done:
case <-timer.C:
}
}
func sameHLSStart(a, b float64) bool {
const tol = 0.75
if a < b {
return b-a < tol
}
return a-b < tol
}
func resetHLSDir(dir string) error {
var lastErr error
for i := 0; i < 6; i++ {
lastErr = os.RemoveAll(dir)
if lastErr == nil {
break
}
time.Sleep(50 * time.Millisecond)
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
// Best-effort: if RemoveAll kept failing on Windows locks, at least drop the playlist
// so WaitReady cannot treat the stale file as belonging to the new job.
_ = os.Remove(filepath.Join(dir, "index.m3u8"))
return 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
}
// SetProbe wires ffprobe so STRM/HLS jobs can persist source duration when the
// media row still has duration_sec=0 (common for .strm that was never probed).
func (t *TranscoderService) SetProbe(probe *FFprobeService) {
if t == nil {
return
}
t.probe = probe
}
func (t *TranscoderService) maybeFillDuration(ctx context.Context, m *model.Media, input transcodeInput) {
if t == nil || t.probe == nil || m == nil || m.DurationSec > 0 || strings.TrimSpace(input.Source) == "" {
return
}
var (
res *ProbeResult
err error
)
if isHTTPSource(input.Source) {
res, err = t.probe.ProbeHTTP(ctx, input.Source, input.Headers)
} else {
res, err = t.probe.Probe(ctx, input.Source)
}
if err != nil || res == nil || res.DurationSec <= 0 {
return
}
m.DurationSec = res.DurationSec
if t.repo == nil || t.repo.DB == nil {
return
}
if err := t.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("id = ?", m.ID).Update("duration_sec", res.DurationSec).Error; err != nil && t.log != nil {
t.log.Debug("persist probed duration failed", zap.String("media_id", m.ID), zap.Error(err))
}
}
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")
}
+14
View File
@@ -0,0 +1,14 @@
//go:build unix
package service
import (
"os/exec"
"syscall"
)
// setFFmpegSysProcAttr puts ffmpeg in its own process group so cancel can
// tear down the whole group (and any helpers) reliably on Linux/macOS.
func setFFmpegSysProcAttr(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
@@ -0,0 +1,9 @@
//go:build windows
package service
import "os/exec"
func setFFmpegSysProcAttr(cmd *exec.Cmd) {
// Windows: CommandContext cancel is enough for the single ffmpeg process.
}
+72 -4
View File
@@ -2,11 +2,20 @@ package service
import (
"fmt"
"net/url"
"sort"
"strconv"
"strings"
"github.com/truewhile/MeBox/internal/config"
)
type transcodeInput struct {
Source string
Headers map[string]string
StartSec float64
}
type ffmpegArgSettings struct {
encoder string
bitrate string
@@ -31,11 +40,18 @@ 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)
// Mid-file restarts must not use -re: output -ss would otherwise crawl to the
// seek point at 1x wall-clock before emitting the first HLS segment.
realtime := settings.realtime && input.StartSec <= 0.05
args := baseFFmpegArgs(video.preInput, realtime)
args = appendInputAndVideoArgs(args, input, settings, video)
args = appendOutputHLSArgs(args, settings, segments, playlist)
return args
}
@@ -111,8 +127,20 @@ 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)...)
ss := ""
if input.StartSec > 0.05 {
ss = strconv.FormatFloat(input.StartSec, 'f', 3, 64)
}
// Always use input -ss (before -i). Output -ss on HTTP/WMV decodes from
// byte 0 up to the offset and cannot meet playlist WaitReady for deep
// scrubbing; CDNs with Range support jump via demuxer seek instead.
if ss != "" {
args = append(args, "-ss", ss)
}
args = append(args, "-i", input.Source)
args = append(args, "-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 +192,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
}
}
+14 -7
View File
@@ -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,31 +31,38 @@ 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.
setFFmpegSysProcAttr(cmd)
cmd.Stderr = os.Stderr
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),
zap.Float64("start_sec", input.StartSec),
)
t.hub.Publish("transcode", map[string]any{
"media_id": job.mediaID,
"encoder": job.encoder,
"status": "started",
"media_id": job.mediaID,
"encoder": job.encoder,
"status": "started",
"start_sec": input.StartSec,
})
if err := cmd.Run(); err != nil && !errors.Is(ctx.Err(), context.Canceled) {
t.log.Warn("ffmpeg exited",
zap.String("media_id", job.mediaID),
zap.Float64("start_sec", input.StartSec),
zap.Error(err),
)
}
t.mu.Lock()
delete(t.jobs, job.mediaID)
// Only drop the map entry if we are still the registered generation.
if cur, ok := t.jobs[job.mediaID]; ok && cur == job {
delete(t.jobs, job.mediaID)
}
t.mu.Unlock()
t.hub.Publish("transcode", map[string]any{
+37 -17
View File
@@ -9,17 +9,30 @@ import (
)
// WaitReady blocks (with a deadline) until the playlist file shows up on
// disk. Returns true on success.
// disk for the *current* job generation. Stale playlists left behind by a
// failed RemoveAll / still-exiting ffmpeg must not unblock a mid-file restart.
func (t *TranscoderService) WaitReady(ctx context.Context, mediaID string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for {
if _, err := os.Stat(t.PlaylistPath(mediaID)); err == nil {
t.mu.Lock()
if j, ok := t.jobs[mediaID]; ok {
j.playlistOK = true
t.mu.Lock()
job, ok := t.jobs[mediaID]
var started time.Time
if ok {
started = job.startedAt
}
t.mu.Unlock()
if ok {
if info, err := os.Stat(t.PlaylistPath(mediaID)); err == nil {
// Allow a small clock skew; reject anything older than this job.
if !info.ModTime().Before(started.Add(-2 * time.Second)) {
t.mu.Lock()
if j, exists := t.jobs[mediaID]; exists {
j.playlistOK = true
}
t.mu.Unlock()
return true
}
}
t.mu.Unlock()
return true
}
if time.Now().After(deadline) || ctx.Err() != nil {
return false
@@ -32,14 +45,18 @@ func (t *TranscoderService) WaitReady(ctx context.Context, mediaID string, timeo
}
}
// StopJob cancels a running ffmpeg process for mediaID, if any.
// StopJob cancels a running ffmpeg process for mediaID, if any, and waits
// briefly for it to exit so a subsequent EnsureJobFrom cannot race-write the
// same HLS directory.
func (t *TranscoderService) StopJob(mediaID string) {
gate := t.mediaStartGate(mediaID)
gate.Lock()
defer gate.Unlock()
t.mu.Lock()
defer t.mu.Unlock()
if j, ok := t.jobs[mediaID]; ok {
j.cancel()
delete(t.jobs, mediaID)
}
prev := t.detachJobLocked(mediaID)
t.mu.Unlock()
waitJobExit(prev, 12*time.Second)
}
// TouchJob records client activity for the HLS playlist or segment. The idle
@@ -60,10 +77,13 @@ func (t *TranscoderService) touchJobLocked(mediaID string) {
// StopAll terminates every running transcode (called on graceful shutdown).
func (t *TranscoderService) StopAll() {
t.mu.Lock()
defer t.mu.Unlock()
for id, j := range t.jobs {
j.cancel()
delete(t.jobs, id)
pending := make([]*hlsJob, 0, len(t.jobs))
for id := range t.jobs {
pending = append(pending, t.detachJobLocked(id))
}
t.mu.Unlock()
for _, j := range pending {
waitJobExit(j, 5*time.Second)
}
}
+185
View File
@@ -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,185 @@ 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)
}
}
func TestBuildFFmpegArgsInputSeekBeforeDashI(t *testing.T) {
cfg := &config.Config{}
cfg.Transcoder.MaxHeight = 720
cfg.Transcoder.SegmentSeconds = 4
args := buildFFmpegArgsForInput(cfg, transcodeInput{
Source: "/x.mkv",
StartSec: 125.5,
}, "/o/x.m3u8", "/o/seg_%05d.ts")
idxSS, idxI := -1, -1
for i, arg := range args {
if arg == "-ss" {
idxSS = i
}
if arg == "-i" && idxI < 0 {
idxI = i
}
}
if idxSS < 0 || idxI < 0 || idxSS > idxI {
t.Fatalf("expected local -ss before -i, args=%v", args)
}
if args[idxSS+1] != "125.500" {
t.Fatalf("start = %q", args[idxSS+1])
}
}
func TestBuildFFmpegArgsHTTPSeekAfterDashI(t *testing.T) {
cfg := &config.Config{}
cfg.Transcoder.MaxHeight = 720
cfg.Transcoder.SegmentSeconds = 4
cfg.Transcoder.Realtime = true
args := buildFFmpegArgsForInput(cfg, transcodeInput{
Source: "https://cdn.example.com/a.wmv",
StartSec: 90,
}, "/o/x.m3u8", "/o/seg_%05d.ts")
joined := " " + strings.Join(args, " ") + " "
if strings.Contains(joined, " -re ") {
t.Fatalf("seek restart must disable -re, got: %s", joined)
}
idxSS, idxI, ssCount := -1, -1, 0
for i, arg := range args {
if arg == "-ss" {
ssCount++
if idxSS < 0 {
idxSS = i
}
}
if arg == "-i" && idxI < 0 {
idxI = i
}
}
if idxSS < 0 || idxI < 0 || idxSS > idxI {
t.Fatalf("expected http -ss before -i, args=%v", args)
}
if ssCount != 1 {
t.Fatalf("expected a single -ss, got %d in %v", ssCount, args)
}
if args[idxSS+1] != "90.000" {
t.Fatalf("start = %q", args[idxSS+1])
}
}
func TestSameHLSStart(t *testing.T) {
if !sameHLSStart(10, 10.2) {
t.Fatal("expected close starts to match")
}
if sameHLSStart(10, 12) {
t.Fatal("expected distant starts to differ")
}
}
func TestShouldReplaceHLSJob(t *testing.T) {
existing := &hlsJob{startSec: 120, seekGen: 1000}
if shouldReplaceHLSJob(existing, 0, 0) {
t.Fatal("untagged start=0 must not clobber seek-tagged job")
}
if shouldReplaceHLSJob(existing, 0, 900) {
t.Fatal("older _seek must not clobber newer job")
}
if !shouldReplaceHLSJob(existing, 200, 1001) {
t.Fatal("newer _seek should replace")
}
if !shouldReplaceHLSJob(&hlsJob{startSec: 0, seekGen: 0}, 120, 1000) {
t.Fatal("seek should replace untagged head job")
}
if shouldReplaceHLSJob(existing, 120.2, 1001) {
t.Fatal("same start should not replace")
}
}
func TestFilterHLSSegmentQueryDropsStart(t *testing.T) {
got := filterHLSSegmentQuery("token=abc&start=120.5&profile_id=1")
if strings.Contains(got, "start=") {
t.Fatalf("start should be stripped, got %q", got)
}
if !strings.Contains(got, "token=abc") || !strings.Contains(got, "profile_id=1") {
t.Fatalf("auth/profile query should remain, got %q", got)
}
}
+22 -2
View File
@@ -146,8 +146,28 @@ export function streamURL(mediaId: string): string {
}
// hlsURL returns the m3u8 playlist URL fed into hls.js.
export function hlsURL(mediaId: string): string {
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}`
// startSec > 0 asks the server to (re)start ffmpeg from that source offset.
export function hlsURL(mediaId: string, startSec = 0): string {
const safeStart = Math.max(0, Math.round(startSec * 1000) / 1000)
// Always send start= (including 0) so the server can tell an intentional
// restart-from-head apart from a missing query on a stale refresh.
const start = `&start=${encodeURIComponent(String(safeStart))}`
// Monotonic-ish client generation: newer seeks win; older in-flight playlist
// requests must not cancel the active ffmpeg job back to t=0.
const bust = `&_seek=${Date.now()}`
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${bust}`
}
// Stop an on-demand HLS job. keepalive makes the request survive page
// navigation/tab close, where an axios promise can be discarded by browsers.
export function stopHLSJob(mediaId: string): void {
const url = `/api/hls/${encodeURIComponent(mediaId)}?${tokenQuery()}${profileQuery()}`
void fetch(url, {
method: 'DELETE',
credentials: 'same-origin',
keepalive: true,
cache: 'no-store',
}).catch(() => undefined)
}
// imageURL converts a remote poster URL into a same-origin proxy URL so it
+36 -12
View File
@@ -46,6 +46,12 @@ type PlayerControlsProps = {
playlistOpen?: boolean
hasPlaylist?: boolean
onTogglePlaylist?: () => void
/** Media metadata duration (seconds). Used when HLS only knows transcoded length. */
knownDuration?: number
/** Absolute source offset of the current HLS session (seconds). */
streamOffset?: number
/** Absolute seek on the full timeline; return true when handled (e.g. HLS restart). */
onSeekAbsolute?: (seconds: number) => boolean
}
export function PlayerControls({
@@ -65,6 +71,9 @@ export function PlayerControls({
playlistOpen = false,
hasPlaylist = false,
onTogglePlaylist,
knownDuration = 0,
streamOffset = 0,
onSeekAbsolute,
}: PlayerControlsProps) {
const video = () => videoRef.current
const container = () =>
@@ -176,13 +185,14 @@ export function PlayerControls({
}
const syncTime = () => {
if (!isScrubbingRef.current) {
setCurrentTime(el.currentTime)
setCurrentTime(streamOffset + el.currentTime)
}
}
const syncMeta = () => {
setDuration(el.duration || 0)
const streamDur = Number.isFinite(el.duration) ? el.duration : 0
setDuration(Math.max(knownDuration || 0, streamOffset + streamDur))
if (!isScrubbingRef.current) {
setCurrentTime(el.currentTime)
setCurrentTime(streamOffset + el.currentTime)
}
}
const syncVolume = () => {
@@ -225,7 +235,14 @@ export function PlayerControls({
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoRef])
}, [videoRef, knownDuration, streamOffset])
// Keep the scrubber max in sync when metadata duration arrives after mount.
useEffect(() => {
const el = video()
const streamDur = el && Number.isFinite(el.duration) ? el.duration : 0
setDuration(Math.max(knownDuration || 0, streamOffset + streamDur))
}, [knownDuration, streamOffset])
// 当悬停或菜单状态改变时,更新控制栏计时器
useEffect(() => {
@@ -248,12 +265,23 @@ export function PlayerControls({
else el.pause()
}
const applyAbsoluteSeek = (absolute: number) => {
const el = video()
if (!el) return
if (onSeekAbsolute?.(absolute)) {
setCurrentTime(absolute)
return
}
const local = Math.max(0, absolute - streamOffset)
el.currentTime = local
setCurrentTime(streamOffset + local)
}
const handleSeekChange = (v: number) => {
setScrubValue(v)
setCurrentTime(v)
const el = video()
if (el && !isScrubbing) {
el.currentTime = v
if (!isScrubbing) {
applyAbsoluteSeek(v)
}
}
@@ -264,11 +292,7 @@ export function PlayerControls({
}
const handleSeekEnd = (v: number) => {
const el = video()
if (el) {
el.currentTime = v
setCurrentTime(v)
}
applyAbsoluteSeek(v)
setIsScrubbing(false)
setScrubValue(null)
}
+124 -31
View File
@@ -4,7 +4,7 @@ import type Hls from 'hls.js'
import toast from 'react-hot-toast'
import { mediaAPI, libraryAPI } from '../api/library'
import { api, hlsURL, streamURL } from '../api/client'
import { hlsURL, stopHLSJob, streamURL } from '../api/client'
import { danmakuAPI, type DanmakuAnime, type DanmakuLoadedInfo } from '../api/danmaku'
import { playbackAPI } from '../api/playback'
import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
@@ -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.
@@ -71,6 +72,8 @@ export function PlayerPage() {
const [directOnly, setDirectOnly] = useState(false)
const [resumePosition, setResumePosition] = useState(0)
const [initialSeekDone, setInitialSeekDone] = useState(false)
// HLS session source offset: playlist t=0 maps to this absolute second.
const [hlsStartSec, setHlsStartSec] = useState(0)
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
const [danmakuOpen, setDanmakuOpen] = useState(false)
@@ -94,14 +97,11 @@ export function PlayerPage() {
const [playlistEpisodes, setPlaylistEpisodes] = useState<Media[]>([])
const [playlistOpen, setPlaylistOpen] = useState(false)
const teardownHls = useCallback((mediaId?: string, stopServer = false) => {
const teardownHls = useCallback(() => {
if (hlsRef.current) {
hlsRef.current.destroy()
hlsRef.current = null
}
if (stopServer && mediaId) {
api.delete(`/hls/${encodeURIComponent(mediaId)}`).catch(() => undefined)
}
}, [])
const backTarget = useCallback(() => {
@@ -186,6 +186,7 @@ export function PlayerPage() {
useEffect(() => {
setMedia(null)
setLoadError('')
setHlsStartSec(0)
setDanmakuEpisodeId(null)
setDanmakuCandidates([])
setDanmakuSearch(null)
@@ -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('')
@@ -238,18 +239,38 @@ export function PlayerPage() {
}, [id, modeParam, directOnly])
// Wire up the actual <video> element when we know the mode.
// Depend on media.id (not the media object): refreshing duration after
// MANIFEST_PARSED must not remount HLS or it storms EnsureJob / DELETE.
const mediaId = media?.id
const mediaRef = useRef(media)
mediaRef.current = media
useEffect(() => {
if (!media || !ref.current) return
if (!mediaId || !ref.current) return
const currentMedia = mediaRef.current
if (!currentMedia) return
let cancelled = false
teardownHls()
const video = ref.current
const durationSec = currentMedia.duration_sec || 0
if (mode === 'hls') {
const url = hlsURL(media.id)
const url = hlsURL(mediaId, hlsStartSec)
void import('hls.js').then(({ default: HlsCtor }) => {
if (cancelled || !ref.current) return
if (HlsCtor.isSupported()) {
const hls = new HlsCtor({ enableWorker: true, lowLatencyMode: false })
hls.loadSource(url)
hls.attachMedia(video)
hls.on(HlsCtor.Events.MANIFEST_PARSED, () => {
// .strm 入库时常缺 duration;转码启动时会补探测,这里刷新一次给进度条总时长。
if (durationSec > 0) return
mediaAPI
.get(mediaId)
.then((fresh) => {
if ((fresh.duration_sec || 0) > 0) setMedia(fresh)
})
.catch(() => undefined)
})
hls.on(HlsCtor.Events.ERROR, (_, data) => {
if (data.fatal) {
setHlsUnavailable(true)
@@ -260,6 +281,10 @@ export function PlayerPage() {
setParams(params, { replace: true })
}
})
if (cancelled) {
hls.destroy()
return
}
hlsRef.current = hls
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = url
@@ -271,19 +296,41 @@ export function PlayerPage() {
}
void video.play().catch(() => undefined)
}).catch(() => {
if (cancelled) return
setHlsUnavailable(true)
setPlayerError('HLS 播放组件加载失败,正在尝试直接播放。')
setMode('direct')
})
} else {
video.src = streamURL(media.id)
if (hlsUnavailable && needsTranscodeForBrowser(media)) {
video.src = streamURL(mediaId)
if (hlsUnavailable && needsTranscodeForBrowser(currentMedia)) {
setPlayerError('当前正在直连播放原始文件;此封装或音轨浏览器兼容性有限,可能只有画面没有声音。请配置本机 ffmpeg 后切回 HLS 转码播放。')
}
void video.play().catch(() => undefined)
}
return () => teardownHls(media.id, mode === 'hls')
}, [hlsUnavailable, media, mode, params, setParams, teardownHls])
return () => {
cancelled = true
teardownHls()
}
}, [hlsUnavailable, hlsStartSec, mediaId, mode, params, setParams, teardownHls])
// Stop host ffmpeg when leaving this HLS player. The keepalive request also
// survives route navigation while the component is being torn down.
useEffect(() => {
if (!mediaId || mode !== 'hls') return
return () => {
stopHLSJob(mediaId)
}
}, [mediaId, mode])
// React cleanup is not guaranteed when a tab/window closes. pagehide fires
// while the document can still dispatch a keepalive request.
useEffect(() => {
if (!mediaId || mode !== 'hls') return
const stopOnPageExit = () => stopHLSJob(mediaId)
window.addEventListener('pagehide', stopOnPageExit)
return () => window.removeEventListener('pagehide', stopOnPageExit)
}, [mediaId, mode])
// 自动拉取已有的播放进度并恢复播放位置
useEffect(() => {
@@ -301,8 +348,21 @@ export function PlayerPage() {
}, [id])
useEffect(() => {
if (!resumePosition || initialSeekDone) return
if (mode === 'hls') {
// Restart transcode near the resume point instead of seeking a short partial playlist.
if (Math.abs(hlsStartSec - resumePosition) > 2) {
setHlsStartSec(resumePosition)
}
setInitialSeekDone(true)
const m = Math.floor(resumePosition / 60)
const s = Math.floor(resumePosition % 60)
const timeStr = `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
toast.success(`已恢复上次播放进度至 ${timeStr}`, { duration: 2500 })
return
}
const video = ref.current
if (!video || !resumePosition || initialSeekDone) return
if (!video) return
const applyResume = () => {
if (resumePosition > 0 && Math.abs(video.currentTime - resumePosition) > 2) {
video.currentTime = resumePosition
@@ -319,18 +379,22 @@ export function PlayerPage() {
video.addEventListener('loadedmetadata', applyResume, { once: true })
return () => video.removeEventListener('loadedmetadata', applyResume)
}
}, [resumePosition, initialSeekDone])
}, [resumePosition, initialSeekDone, mode, hlsStartSec])
// Persist resume position every 10 seconds while playing, and immediately upon pause/unmount.
useEffect(() => {
if (!media || !ref.current) return
const video = ref.current
const absolutePositionMs = () =>
Math.floor(((mode === 'hls' ? hlsStartSec : 0) + video.currentTime) * 1000)
const absoluteDurationMs = () =>
Math.floor(Math.max(media.duration_sec || 0, (mode === 'hls' ? hlsStartSec : 0) + (video.duration || 0)) * 1000)
const handler = () => {
const now = Date.now()
if (now - lastSentRef.current < 10_000) return
lastSentRef.current = now
const positionMs = Math.floor(video.currentTime * 1000)
const durationMs = Math.floor((video.duration || 0) * 1000)
const positionMs = absolutePositionMs()
const durationMs = absoluteDurationMs()
if (positionMs > 0) {
playbackAPI.recordProgress(media.id, positionMs, durationMs).catch(() => undefined)
}
@@ -340,13 +404,13 @@ export function PlayerPage() {
return () => {
video.removeEventListener('timeupdate', handler)
video.removeEventListener('pause', handler)
const positionMs = Math.floor(video.currentTime * 1000)
const durationMs = Math.floor((video.duration || 0) * 1000)
const positionMs = absolutePositionMs()
const durationMs = absoluteDurationMs()
if (positionMs > 0 && media) {
playbackAPI.recordProgress(media.id, positionMs, durationMs).catch(() => undefined)
}
}
}, [media])
}, [media, mode, hlsStartSec])
// 加载剧集/播放列表
useEffect(() => {
@@ -520,11 +584,46 @@ export function PlayerPage() {
return
}
const next = mode === 'hls' ? 'direct' : 'hls'
if (next === 'hls') {
setHlsStartSec(0)
}
setMode(next)
params.set('mode', next)
setParams(params, { replace: true })
}, [isDirectStream, mode, params, setParams])
const handleSeekAbsolute = useCallback(
(absoluteSec: number) => {
if (mode !== 'hls') return false
const video = ref.current
if (!video) return false
const target = Math.max(0, absoluteSec)
const local = target - hlsStartSec
// Live/EVENT HLS during transcoding often reports duration=Infinity.
// Never treat that as "already buffered" or currentTime seeks reset to 0.
const finiteDuration = Number.isFinite(video.duration) ? video.duration : 0
let seekableEnd = 0
if (video.seekable && video.seekable.length > 0) {
try {
seekableEnd = video.seekable.end(video.seekable.length - 1)
} catch {
seekableEnd = 0
}
}
if (!Number.isFinite(seekableEnd)) seekableEnd = 0
const windowEnd = Math.max(finiteDuration, seekableEnd)
if (local >= 0 && windowEnd > 0.5 && local <= windowEnd - 0.5) {
video.currentTime = local
return true
}
setPlayerError('')
toast('正在从该位置重新转码…', { duration: 2000 })
setHlsStartSec(target)
return true
},
[hlsStartSec, mode],
)
// 用户切换字幕轨道:-1=关闭;记忆偏好,下次播放默认沿用。
const selectSubtitle = useCallback((index: number) => {
setSubtitleIndex(index)
@@ -539,12 +638,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 +671,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}
@@ -622,6 +712,9 @@ export function PlayerPage() {
playlistOpen={playlistOpen}
hasPlaylist={playlistEpisodes.length > 0}
onTogglePlaylist={togglePlaylistOpen}
knownDuration={media?.duration_sec || 0}
streamOffset={mode === 'hls' ? hlsStartSec : 0}
onSeekAbsolute={mode === 'hls' ? handleSeekAbsolute : undefined}
playlistPanel={
<PlayerPlaylistPanel
open={playlistOpen}
+9
View File
@@ -42,6 +42,9 @@ type PlayerVideoStageProps = {
playlistOpen?: boolean
hasPlaylist?: boolean
onTogglePlaylist?: () => void
knownDuration?: number
streamOffset?: number
onSeekAbsolute?: (seconds: number) => boolean
}
export function PlayerVideoStage({
@@ -75,6 +78,9 @@ export function PlayerVideoStage({
playlistOpen,
hasPlaylist,
onTogglePlaylist,
knownDuration,
streamOffset,
onSeekAbsolute,
}: PlayerVideoStageProps) {
const stageRef = useRef<HTMLDivElement>(null)
const [videoRatio, setVideoRatio] = useState<number | null>(null)
@@ -319,6 +325,9 @@ export function PlayerVideoStage({
playlistOpen={playlistOpen}
hasPlaylist={hasPlaylist}
onTogglePlaylist={onTogglePlaylist}
knownDuration={knownDuration}
streamOffset={streamOffset}
onSeekAbsolute={onSeekAbsolute}
/>
{danmakuPanel}
{playlistPanel}
+11 -6
View File
@@ -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()