From f3546ab2dbb19744ea1e1bfaef4c6c1c06e61828 Mon Sep 17 00:00:00 2001 From: yvan Date: Fri, 18 Sep 2026 16:06:31 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=90=E8=BF=9B=E5=BC=8F=E7=94=9F=E6=88=90?= =?UTF-8?q?=E6=92=AD=E6=94=BE=EF=BC=9AfMP4=20=E5=89=8D=204=20=E4=B8=AA?= =?UTF-8?q?=E5=88=86=E7=89=87=E5=B0=B1=E7=BB=AA=E5=8D=B3=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=88=97=E8=A1=A8=E5=BC=80=E5=A7=8B=E8=B5=B7?= =?UTF-8?q?=E6=92=AD=EF=BC=8C=E5=90=8E=E5=8F=B0=E8=BE=B9=E8=BD=AC=E8=BE=B9?= =?UTF-8?q?=E8=A1=A5=20ENDLIST=EF=BC=8C=E6=B6=88=E9=99=A4=E6=95=B4?= =?UTF-8?q?=E9=9B=86=E5=B0=81=E8=A3=85=E7=AD=89=E5=BE=85=E7=9A=84=E9=BB=91?= =?UTF-8?q?=E5=B1=8F=E7=BC=93=E5=86=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/app/emby_playback.go | 2 +- internal/app/emby_playback_test.go | 2 +- internal/app/playback_gateway_test.go | 2 +- internal/app/playback_generated.go | 202 ++++++++++++++++++++------ internal/app/playback_plan_test.go | 9 +- internal/app/ui_playback_plan.go | 8 +- internal/webui/player.js | 2 +- 7 files changed, 170 insertions(+), 57 deletions(-) diff --git a/internal/app/emby_playback.go b/internal/app/emby_playback.go index 8cd75ce..82f2e9d 100644 --- a/internal/app/emby_playback.go +++ b/internal/app/emby_playback.go @@ -190,7 +190,7 @@ func (app *UIApp) prepareEmbyPlayback(parent context.Context, session *playbackS } else if session.embyProcessing == "audio" { mode = "audio" } - err = app.planMedia(ctx, media, playbackClientCapabilities{MP4: true, NativeHLS: true}, mode, sourceFromDramaID(task.DramaID), origin, base, query) + err = app.planMedia(ctx, media, playbackClientCapabilities{MP4: true, NativeHLS: true}, mode, sourceFromDramaID(task.DramaID), origin, base, query, 0) if err == nil { duration = media.plan.Duration if media.plan.Player == "legacy" { diff --git a/internal/app/emby_playback_test.go b/internal/app/emby_playback_test.go index 8cd6fdf..478dce7 100644 --- a/internal/app/emby_playback_test.go +++ b/internal/app/emby_playback_test.go @@ -273,7 +273,7 @@ func TestEmbyEvictedRemuxRestoresExistingSegmentURLs(t *testing.T) { app.playbackMu.Unlock() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - if err := app.generatePlayback(ctx, media, "remux", base, parsed.RawQuery); err != nil { + if err := app.generatePlayback(ctx, media, "remux", base, parsed.RawQuery, 0); err != nil { t.Fatal(err) } segment := media.generated.assetURL(media.generated.segments[0].Name) diff --git a/internal/app/playback_gateway_test.go b/internal/app/playback_gateway_test.go index 414c940..72a29c1 100644 --- a/internal/app/playback_gateway_test.go +++ b/internal/app/playback_gateway_test.go @@ -161,7 +161,7 @@ func TestPlaybackPlanConvertsOnlyUnsupportedAudio(t *testing.T) { t.Fatal(err) } defer other.Close() - if err := app.planMedia(context.Background(), other, playbackClientCapabilities{MP4: true, NativeHLS: true, Video: []string{"hevc"}, Audio: []string{"aac"}}, "auto", sourceHongguo, "", "/media/", ""); err != nil || other.plan.Player != "legacy" || other.plan.Reason != "video_codec_unsupported" { + if err := app.planMedia(context.Background(), other, playbackClientCapabilities{MP4: true, NativeHLS: true, Video: []string{"hevc"}, Audio: []string{"aac"}}, "auto", sourceHongguo, "", "/media/", "", 0); err != nil || other.plan.Player != "legacy" || other.plan.Reason != "video_codec_unsupported" { t.Fatal("unsupported video did not choose the final compatibility fallback", other.plan, err) } } diff --git a/internal/app/playback_generated.go b/internal/app/playback_generated.go index 470271e..411dc67 100644 --- a/internal/app/playback_generated.go +++ b/internal/app/playback_generated.go @@ -11,9 +11,12 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "time" ) +const playbackProgressiveSegments = 4 + type playbackSegmentIndex struct { Name string Start float64 @@ -28,14 +31,22 @@ type playbackGenerated struct { query string playlist string segments []playbackSegmentIndex + lastEnd float64 files map[string]bool bytes int64 resources *playbackResources done chan struct{} - once sync.Once + doneOnce sync.Once + closeOnce sync.Once + complete atomic.Bool + failed atomic.Bool mu sync.RWMutex } +func (generated *playbackGenerated) finish() { + generated.doneOnce.Do(func() { close(generated.done) }) +} + func playbackGeneratedArgs(media providerMedia, input, directory, processing string) []string { args := playbackInputArgs(media, input, 0) if processing == "audio" { @@ -44,11 +55,11 @@ func playbackGeneratedArgs(media providerMedia, input, directory, processing str args = append(args, "-c", "copy") } return append(args, "-avoid_negative_ts", "make_zero", "-f", "hls", "-hls_time", "2", "-hls_list_size", "0", - "-hls_playlist_type", "vod", "-hls_segment_type", "fmp4", "-hls_fmp4_init_filename", "init.mp4", + "-hls_playlist_type", "event", "-hls_segment_type", "fmp4", "-hls_fmp4_init_filename", "init.mp4", "-hls_flags", "temp_file", "-hls_segment_filename", filepath.ToSlash(filepath.Join(directory, "%06d.m4s")), filepath.ToSlash(filepath.Join(directory, "index.m3u8"))) } -func (app *UIApp) generatePlayback(ctx context.Context, media *playbackMediaSession, processing, base, query string) error { +func (app *UIApp) generatePlayback(ctx context.Context, media *playbackMediaSession, processing, base, query string, startSeconds float64) error { ffmpeg, err := app.downloader.ensureFFmpeg(ctx) if err != nil { return err @@ -57,26 +68,25 @@ func (app *UIApp) generatePlayback(ctx context.Context, media *playbackMediaSess if err != nil { return err } - defer release() directory, err := os.MkdirTemp("", "juku-playback-media-") if err != nil { + release() return err } jobCtx, cancel := context.WithCancel(media.ctx) - stop := context.AfterFunc(ctx, cancel) - defer stop() generated := &playbackGenerated{ctx: jobCtx, cancel: cancel, directory: directory, base: base, query: query, resources: app.mediaResources(), done: make(chan struct{}), files: make(map[string]bool)} media.generated = generated - defer close(generated.done) input := media.local var proxy *hlsProxy if input == "" { proxy, err = app.downloader.newHLSProxy(jobCtx, media.media, media.key) if err != nil { + generated.finish() + cancel() + release() return err } - defer proxy.Close() proxy.acquire = func(ctx context.Context) (func(), error) { return app.mediaResources().acquire(ctx, "media", false) } input = proxy.root } @@ -84,47 +94,110 @@ func (app *UIApp) generatePlayback(ctx context.Context, media *playbackMediaSess log := &playbackLog{text: cappedStringWriter{limit: 64 * 1024}, ready: make(chan struct{})} command.Stderr = log if err := command.Start(); err != nil { + generated.finish() + cancel() + release() return fmt.Errorf("无法启动媒体封装:%w", err) } done := make(chan error, 1) go func() { done <- command.Wait() }() - ticker := time.NewTicker(50 * time.Millisecond) + go func() { // 后台:持续刷新渐进式播放列表,等 ffmpeg 收尾后补 ENDLIST 并释放转封装资源 + defer generated.finish() + defer release() + ticker := time.NewTicker(300 * time.Millisecond) + defer ticker.Stop() + for { + select { + case waitErr := <-done: + if waitErr != nil { + if jobCtx.Err() == nil { + generated.failed.Store(true) + app.downloader.recordDiagnostic(diagnosticEvent{Level: "warn", Event: "playback.generated.failed", + Message: "媒体封装中断:" + fmt.Sprint(waitErr)}) + } + return + } + _ = generated.accountCache() + if data, err := os.ReadFile(filepath.Join(generated.directory, "index.m3u8")); err == nil { + _ = generated.index(string(data)) + } + return + case <-jobCtx.Done(): + <-done + return + case <-ticker.C: + if err := generated.accountCache(); err != nil { + generated.failed.Store(true) + cancel() + <-done + return + } + if data, err := os.ReadFile(filepath.Join(generated.directory, "index.m3u8")); err == nil { + _ = generated.index(string(data)) + } + } + } + }() + // 前台:前几个分片就绪(并覆盖起播位置)即返回计划,剩余部分边播边转 + ticker := time.NewTicker(80 * time.Millisecond) defer ticker.Stop() for { select { - case err := <-done: - if err != nil { - return playbackStreamError(jobCtx, proxy, log, media.media, err, nil) + case <-generated.done: + if ctx.Err() != nil { + return ctx.Err() } - if err = generated.accountCache(); err != nil { - return err + generated.mu.RLock() + complete, failed := generated.complete.Load(), generated.failed.Load() + segmentCount := len(generated.segments) + lastEnd := generated.lastEnd + generated.mu.RUnlock() + if failed || !complete || segmentCount == 0 { + return errors.New("媒体封装未能完成") } - body, err := os.ReadFile(filepath.Join(directory, "index.m3u8")) - if err != nil { - return err - } - if err := generated.index(string(body)); err != nil { - return err - } - media.plan.URL = generated.assetURL("index.m3u8") - media.plan.Player, media.plan.MIME, media.plan.Processing = "hls", "application/vnd.apple.mpegurl", processing - media.plan.Duration = generated.segments[len(generated.segments)-1].Start + generated.segments[len(generated.segments)-1].Duration - media.plan.SeekEnd = media.plan.Duration + generated.applyPlan(media, processing, lastEnd) return nil + case <-ctx.Done(): + cancel() + <-generated.done + return ctx.Err() case <-ticker.C: if err := generated.accountCache(); err != nil { cancel() - <-done + <-generated.done return err } - case <-ctx.Done(): - cancel() - <-done - return ctx.Err() + data, err := os.ReadFile(filepath.Join(generated.directory, "index.m3u8")) + if err != nil { + continue + } + body := string(data) + if strings.Count(body, "#EXTINF") < playbackProgressiveSegments { + continue + } + if err := generated.index(body); err != nil { + continue + } + generated.mu.RLock() + lastEnd := generated.lastEnd + generated.mu.RUnlock() + if lastEnd < startSeconds+6 { + continue + } + generated.applyPlan(media, processing, lastEnd) + return nil } } } +func (generated *playbackGenerated) applyPlan(media *playbackMediaSession, processing string, lastEnd float64) { + media.plan.URL = generated.assetURL("index.m3u8") + media.plan.Player, media.plan.MIME, media.plan.Processing = "hls", "application/vnd.apple.mpegurl", processing + if media.plan.Duration <= 0 { + media.plan.Duration, media.plan.SeekEnd = lastEnd, lastEnd + } +} + func (generated *playbackGenerated) accountCache() error { entries, err := os.ReadDir(generated.directory) if err != nil { @@ -164,50 +237,63 @@ func (generated *playbackGenerated) assetURL(name string) string { } func (generated *playbackGenerated) index(playlist string) error { - if len(playlist) > 2<<20 || !strings.HasPrefix(playlist, "#EXTM3U") || !strings.Contains(playlist, "#EXT-X-ENDLIST") { - return errors.New("媒体封装尚未完成,不能生成完整播放索引") + complete := strings.Contains(playlist, "#EXT-X-ENDLIST") + if len(playlist) > 2<<20 || !strings.HasPrefix(playlist, "#EXTM3U") { + return errors.New("媒体封装尚未就绪") } lines := strings.Split(playlist, "\n") - var start, duration, longest float64 + segments := make([]playbackSegmentIndex, 0, 64) + files := make(map[string]bool, 64) + var start, duration float64 validName := func(name string) bool { return name != "" && filepath.Base(name) == name && !strings.ContainsAny(name, "\\?\x00") && (strings.HasSuffix(name, ".mp4") || strings.HasSuffix(name, ".m4s")) } for i, line := range lines { if strings.HasPrefix(line, "#EXTINF:") { value, _, _ := strings.Cut(strings.TrimPrefix(line, "#EXTINF:"), ",") - var err error - duration, err = strconv.ParseFloat(value, 64) - if err != nil || math.IsNaN(duration) || math.IsInf(duration, 0) || duration <= 0 || duration > 3600 { + parsed, err := strconv.ParseFloat(value, 64) + if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed <= 0 || parsed > 3600 { return errors.New("媒体分片时长无效") } - longest = math.Max(longest, duration) + duration = parsed } else if strings.HasPrefix(line, "#EXT-X-MAP:") { matches := hlsURIAttribute.FindStringSubmatch(line) if len(matches) != 2 || !validName(matches[1]) { return errors.New("媒体初始化片段无效") } - generated.files[matches[1]] = true + files[matches[1]] = true lines[i] = strings.Replace(line, matches[0], `URI="`+generated.assetURL(matches[1])+`"`, 1) } else if line != "" && !strings.HasPrefix(line, "#") { - if duration <= 0 || !validName(line) || len(generated.segments) >= 16384 || start+duration > 24*60*60 { + if duration <= 0 || !validName(line) || len(segments) >= 16384 || start+duration > 24*60*60 { return errors.New("媒体分片索引无效") } - generated.files[line] = true - generated.segments = append(generated.segments, playbackSegmentIndex{Name: line, Start: start, Duration: duration}) + files[line] = true + segments = append(segments, playbackSegmentIndex{Name: line, Start: start, Duration: duration}) start += duration duration = 0 lines[i] = generated.assetURL(line) } } - if len(generated.segments) == 0 || duration != 0 { + if !complete && duration > 0 { + duration = 0 + } + if len(segments) == 0 || (complete && duration != 0) { return errors.New("媒体播放索引没有完整分片") } + if !complete && len(segments) < len(generated.segments) { + return nil + } for i, line := range lines { if strings.HasPrefix(line, "#EXT-X-TARGETDURATION:") { + var longest float64 + for _, segment := range segments { + longest = math.Max(longest, segment.Duration) + } lines[i] = "#EXT-X-TARGETDURATION:" + strconv.Itoa(int(math.Ceil(longest))) + break } } - for name := range generated.files { + for name := range files { info, err := os.Stat(filepath.Join(generated.directory, name)) if err != nil { return fmt.Errorf("媒体分片未完整生成(%s):%w", name, err) @@ -219,25 +305,41 @@ func (generated *playbackGenerated) index(playlist string) error { return fmt.Errorf("媒体分片未完整生成(%s 为空文件)", name) } } + generated.mu.Lock() + generated.segments = segments + generated.files = files + generated.lastEnd = start generated.playlist = strings.Join(lines, "\n") + generated.mu.Unlock() + generated.complete.Store(complete) return nil } func (generated *playbackGenerated) Close() { - generated.once.Do(func() { + generated.closeOnce.Do(func() { generated.cancel() go func() { <-generated.done - generated.mu.Lock() - defer generated.mu.Unlock() - _ = os.RemoveAll(generated.directory) + removeMediaDirectory(generated.directory) generated.resources.mu.Lock() + defer generated.resources.mu.Unlock() generated.resources.cacheBytes -= generated.bytes - generated.resources.mu.Unlock() }() }) } +func removeMediaDirectory(directory string) { + for attempt := 0; attempt < 30; attempt++ { + err := os.RemoveAll(directory) + if err == nil { + if _, statErr := os.Stat(directory); os.IsNotExist(statErr) { + return + } + } + time.Sleep(200 * time.Millisecond) + } +} + func (generated *playbackGenerated) ServeHTTP(writer http.ResponseWriter, request *http.Request) { generated.mu.RLock() defer generated.mu.RUnlock() @@ -247,6 +349,10 @@ func (generated *playbackGenerated) ServeHTTP(writer http.ResponseWriter, reques } name := strings.TrimPrefix(request.URL.Path, generated.base) if name == "index.m3u8" { + if generated.failed.Load() && !generated.complete.Load() { + writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "媒体封装中断,请重新播放"}) + return + } writer.Header().Set("Content-Type", "application/vnd.apple.mpegurl") http.ServeContent(writer, request, name, time.Time{}, strings.NewReader(generated.playlist)) return diff --git a/internal/app/playback_plan_test.go b/internal/app/playback_plan_test.go index ec37b13..1bc2f9a 100644 --- a/internal/app/playback_plan_test.go +++ b/internal/app/playback_plan_test.go @@ -215,7 +215,7 @@ func TestPlaybackGeneratedPreservesLongGOPAndAudioOnlyVideo(t *testing.T) { } ctx, stop := context.WithTimeout(parent, 30*time.Second) defer stop() - if err := app.generatePlayback(ctx, media, processing, "/media/", ""); err != nil { + if err := app.generatePlayback(ctx, media, processing, "/media/", "", 0); err != nil { media.Close() t.Fatal(err) } @@ -224,6 +224,13 @@ func TestPlaybackGeneratedPreservesLongGOPAndAudioOnlyVideo(t *testing.T) { if media.generated.ctx.Err() != nil { t.Fatal("completed media inherited its preparation timeout") } + completeDeadline := time.Now().Add(20 * time.Second) + for !media.generated.complete.Load() { + if time.Now().After(completeDeadline) { + t.Fatal("background remux did not complete") + } + time.Sleep(20 * time.Millisecond) + } parts := media.generated.segments if len(parts) != 4 || parts[0].Duration < 2.99 || parts[0].Duration > 3.01 || parts[1].Start != parts[0].Duration || !strings.Contains(media.generated.playlist, "#EXT-X-TARGETDURATION:3") { t.Fatalf("invented fixed two-second timeline: %+v", parts) diff --git a/internal/app/ui_playback_plan.go b/internal/app/ui_playback_plan.go index b413fca..c0fb898 100644 --- a/internal/app/ui_playback_plan.go +++ b/internal/app/ui_playback_plan.go @@ -84,13 +84,13 @@ func (app *UIApp) probePlaybackCodecs(ctx context.Context, media *playbackMediaS if proxy != nil && proxy.Err() != nil || media.info.Video == "" && err != nil { return playbackStreamError(ctx, proxy, log, media.media, err, nil) } - if media.plan.Duration <= 0 && duration > 0 { + if duration > 0 && duration != media.plan.Duration { media.plan.Duration, media.plan.SeekEnd = duration, duration } return nil } -func (app *UIApp) planMedia(ctx context.Context, media *playbackMediaSession, client playbackClientCapabilities, mode, source, origin, base, query string) error { +func (app *UIApp) planMedia(ctx context.Context, media *playbackMediaSession, client playbackClientCapabilities, mode, source, origin, base, query string, startSeconds float64) error { app.attachMediaGateway(media, base, query) app.inspectPlaybackMedia(ctx, media) if mode == "legacy" { @@ -124,7 +124,7 @@ func (app *UIApp) planMedia(ctx context.Context, media *playbackMediaSession, cl media.plan.Player, media.plan.Processing, media.plan.Reason = "legacy", "video", "hls_player_unavailable" return nil } - if err := app.generatePlayback(ctx, media, processing, base, query); err != nil { + if err := app.generatePlayback(ctx, media, processing, base, query, startSeconds); err != nil { return err } } else if media.plan.Player == "hls" && !client.NativeHLS && !client.HlsJS || media.plan.Player == "mp4" && !client.MP4 { @@ -198,7 +198,7 @@ func (app *UIApp) handlePlaybackPlan(writer http.ResponseWriter, request *http.R media, err = app.resolveMediaSession(run.ctx, ctx, run.task, run.downloadID, input.Quality, run.cache) if err == nil { base := "/api/ui/playback/media/" + run.id + "/" + strconv.FormatUint(run.run, 10) + "/" - err = app.planMedia(ctx, media, input.Client, input.Mode, sourceFromDramaID(run.task.DramaID), request.Header.Get("Origin"), base, "") + err = app.planMedia(ctx, media, input.Client, input.Mode, sourceFromDramaID(run.task.DramaID), request.Header.Get("Origin"), base, "", input.Start) } if err != nil { app.finishPlaybackRun(run, err, request.Context().Err() != nil) diff --git a/internal/webui/player.js b/internal/webui/player.js index 5f859b9..82d13fe 100644 --- a/internal/webui/player.js +++ b/internal/webui/player.js @@ -653,7 +653,7 @@ statusText.textContent = '正在缓冲…'; await mediaLoader.load(result, supportsNativePlayback(), signal, offset); if (signal.aborted || version !== streamVersion) throw abortError(); - if (Number.isFinite(video.duration) && video.duration > 0) playbackDuration = video.duration; + if (Number.isFinite(video.duration) && video.duration > playbackDuration) playbackDuration = video.duration; if (offset > 0) video.currentTime = playbackDuration > 0 ? Math.min(offset, Math.max(0, playbackDuration - 0.05)) : offset; video.playbackRate = Number(node('playbackRate').value) || 1; loading = false;