From 5a47e6f52d6eecb7a737bb6701f392be9200053c Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:21:24 +0800 Subject: [PATCH] =?UTF-8?q?bug=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/service/stream_hls.go | 3 +- internal/service/transcoder.go | 21 ++++++++++++-- internal/service/transcoder_ffmpeg_args.go | 22 +++++++++++---- internal/service/transcoder_jobs.go | 27 +++++++++++++----- internal/service/transcoder_test.go | 32 +++++++++++++++++++++- web/src/api/client.ts | 7 +++-- web/src/pages/PlayerPage.tsx | 17 ++++++++++-- 7 files changed, 108 insertions(+), 21 deletions(-) diff --git a/internal/service/stream_hls.go b/internal/service/stream_hls.go index 3347481..b42793b 100644 --- a/internal/service/stream_hls.go +++ b/internal/service/stream_hls.go @@ -109,7 +109,8 @@ func filterHLSSegmentQuery(rawQuery string) string { if i := strings.IndexByte(part, '='); i >= 0 { key = part[:i] } - if strings.EqualFold(key, "start") { + switch strings.ToLower(key) { + case "start", "_seek": continue } kept = append(kept, part) diff --git a/internal/service/transcoder.go b/internal/service/transcoder.go index 402263e..f22f75c 100644 --- a/internal/service/transcoder.go +++ b/internal/service/transcoder.go @@ -146,8 +146,7 @@ func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, s outDir := t.HLSDir(mediaID) // Wipe prior segments so a mid-file restart cannot serve stale early chunks. - _ = os.RemoveAll(outDir) - if err := os.MkdirAll(outDir, 0o750); err != nil { + if err := resetHLSDir(outDir); err != nil { return "", err } @@ -192,6 +191,24 @@ func sameHLSStart(a, b float64) bool { 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)) { diff --git a/internal/service/transcoder_ffmpeg_args.go b/internal/service/transcoder_ffmpeg_args.go index c87074d..c6a5c33 100644 --- a/internal/service/transcoder_ffmpeg_args.go +++ b/internal/service/transcoder_ffmpeg_args.go @@ -47,7 +47,10 @@ func buildFFmpegArgsForInput(cfg *config.Config, input transcodeInput, playlist, settings := ffmpegArgSettingsFromConfig(cfg) video := ffmpegVideoPlanForSettings(settings) - args := baseFFmpegArgs(video.preInput, settings.realtime) + // 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 @@ -126,12 +129,21 @@ func baseFFmpegArgs(preInput string, realtime bool) []string { func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpegArgSettings, video ffmpegVideoPlan) []string { args = append(args, ffmpegHTTPInputArgs(input)...) - // Input seek (-ss before -i) lets mid-file HLS restarts jump without - // decoding everything before the click position. + ss := "" if input.StartSec > 0.05 { - args = append(args, "-ss", strconv.FormatFloat(input.StartSec, 'f', 3, 64)) + ss = strconv.FormatFloat(input.StartSec, 'f', 3, 64) } - args = append(args, "-i", input.Source, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec) + // Local files: input -ss (byte/keyframe seek). HTTP/STRM: output -ss after + // -i — several cloud/WMV demuxers ignore input seeks and would otherwise + // restart from t=0. Realtime (-re) is already disabled for StartSec > 0. + if ss != "" && !isHTTPSource(input.Source) { + args = append(args, "-ss", ss) + } + args = append(args, "-i", input.Source) + if ss != "" && isHTTPSource(input.Source) { + args = append(args, "-ss", ss) + } + 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)) } diff --git a/internal/service/transcoder_jobs.go b/internal/service/transcoder_jobs.go index 5902e5d..375dd82 100644 --- a/internal/service/transcoder_jobs.go +++ b/internal/service/transcoder_jobs.go @@ -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 diff --git a/internal/service/transcoder_test.go b/internal/service/transcoder_test.go index 7d6885f..b7dbde5 100644 --- a/internal/service/transcoder_test.go +++ b/internal/service/transcoder_test.go @@ -212,13 +212,43 @@ func TestBuildFFmpegArgsInputSeekBeforeDashI(t *testing.T) { } } if idxSS < 0 || idxI < 0 || idxSS > idxI { - t.Fatalf("expected -ss before -i, args=%v", args) + 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 := -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 http -ss after -i, args=%v", 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") diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 66086db..7ddfc9a 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -148,8 +148,11 @@ export function streamURL(mediaId: string): string { // hlsURL returns the m3u8 playlist URL fed into hls.js. // startSec > 0 asks the server to (re)start ffmpeg from that source offset. export function hlsURL(mediaId: string, startSec = 0): string { - const start = startSec > 0.05 ? `&start=${encodeURIComponent(String(Math.floor(startSec * 1000) / 1000))}` : '' - return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}` + const start = + startSec > 0.05 ? `&start=${encodeURIComponent(String(Math.round(startSec * 1000) / 1000))}` : '' + // Cache-bust so a seek restart cannot reuse a stale start=0 playlist from disk/browser. + const bust = startSec > 0.05 ? `&_seek=${Date.now()}` : '' + return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}${start}${bust}` } // imageURL converts a remote poster URL into a same-origin proxy URL so it diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index 285a0b4..cae0302 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -575,9 +575,20 @@ export function PlayerPage() { if (!video) return false const target = Math.max(0, absoluteSec) const local = target - hlsStartSec - const available = Number.isFinite(video.duration) ? video.duration : 0 - // Stay inside the already-transcoded window of this HLS session. - if (local >= 0 && local <= Math.max(0, available - 0.5)) { + // 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 }