mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 04:16:38 +08:00
优化转码逻辑
This commit is contained in:
@@ -123,6 +123,7 @@ func (b *serviceContainerBuilder) initContentServices() {
|
|||||||
b.c.Danmaku.SetStrmResolver(b.c.Strm.ResolvePlay)
|
b.c.Danmaku.SetStrmResolver(b.c.Strm.ResolvePlay)
|
||||||
// STRM 直连失败后的 HLS 转码:把 .strm 解析成 ffmpeg 可读取的本地路径或 HTTP 直链。
|
// STRM 直连失败后的 HLS 转码:把 .strm 解析成 ffmpeg 可读取的本地路径或 HTTP 直链。
|
||||||
b.c.Transcoder.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
|
b.c.Transcoder.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
|
||||||
|
b.c.Transcoder.SetProbe(b.c.FFprobe)
|
||||||
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
|
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
|
||||||
if b.c.EmbyRemote != nil {
|
if b.c.EmbyRemote != nil {
|
||||||
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
|
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
|
||||||
|
|||||||
@@ -6,19 +6,25 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServeHLSPlaylist makes sure a transcode is running and writes the m3u8.
|
// ServeHLSPlaylist makes sure a transcode is running and writes the m3u8.
|
||||||
// We block (with a 30s timeout) until the playlist file shows up.
|
// 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 {
|
func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request, mediaID string) error {
|
||||||
// 「客户端直连解码」模式下宿主机不提供转码,HLS 一律拒绝,
|
// 「客户端直连解码」模式下宿主机不提供转码,HLS 一律拒绝,
|
||||||
// 迫使播放器走 direct play 本地解码。
|
// 迫使播放器走 direct play 本地解码。
|
||||||
if s.directPlayOnly(r.Context()) {
|
if s.directPlayOnly(r.Context()) {
|
||||||
return ErrTranscodeDisabled
|
return ErrTranscodeDisabled
|
||||||
}
|
}
|
||||||
if _, err := s.transcoder.EnsureJob(r.Context(), mediaID); err != nil {
|
startSec := parseHLSStartSec(r)
|
||||||
|
if _, err := s.transcoder.EnsureJobFrom(r.Context(), mediaID, startSec); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.transcoder.TouchJob(mediaID)
|
s.transcoder.TouchJob(mediaID)
|
||||||
@@ -48,10 +54,31 @@ func (s *StreamService) ServeHLSPlaylist(w http.ResponseWriter, r *http.Request,
|
|||||||
return nil
|
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 appendQueryToHLSSegments(playlist, rawQuery string) string {
|
func appendQueryToHLSSegments(playlist, rawQuery string) string {
|
||||||
if strings.TrimSpace(rawQuery) == "" {
|
if strings.TrimSpace(rawQuery) == "" {
|
||||||
return playlist
|
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")
|
lines := strings.SplitAfter(playlist, "\n")
|
||||||
for i, line := range lines {
|
for i, line := range lines {
|
||||||
trimmed := strings.TrimSpace(line)
|
trimmed := strings.TrimSpace(line)
|
||||||
@@ -65,12 +92,31 @@ func appendQueryToHLSSegments(playlist, rawQuery string) string {
|
|||||||
} else if strings.HasSuffix(line, "\n") {
|
} else if strings.HasSuffix(line, "\n") {
|
||||||
lineEnding = "\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, "")
|
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]
|
||||||
|
}
|
||||||
|
if strings.EqualFold(key, "start") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, part)
|
||||||
|
}
|
||||||
|
return strings.Join(kept, "&")
|
||||||
|
}
|
||||||
|
|
||||||
// ServeHLSSegment writes a single .ts segment from the on-disk cache.
|
// 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 {
|
func (s *StreamService) ServeHLSSegment(w http.ResponseWriter, r *http.Request, mediaID, segment string) error {
|
||||||
s.transcoder.TouchJob(mediaID)
|
s.transcoder.TouchJob(mediaID)
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ type TranscoderService struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
jobs map[string]*hlsJob
|
jobs map[string]*hlsJob
|
||||||
strmResolve func(ctx context.Context, raw string) (*StrmPlayResult, error)
|
strmResolve func(ctx context.Context, raw string) (*StrmPlayResult, error)
|
||||||
|
probe *FFprobeService
|
||||||
}
|
}
|
||||||
|
|
||||||
// hlsJob holds the live state of one ffmpeg run.
|
// hlsJob holds the live state of one ffmpeg run.
|
||||||
@@ -61,6 +62,9 @@ type hlsJob struct {
|
|||||||
lastAccess time.Time
|
lastAccess time.Time
|
||||||
playlistOK bool
|
playlistOK bool
|
||||||
encoder string
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -92,13 +96,24 @@ func (t *TranscoderService) PlaylistPath(mediaID string) string {
|
|||||||
return filepath.Join(t.HLSDir(mediaID), "index.m3u8")
|
return filepath.Join(t.HLSDir(mediaID), "index.m3u8")
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureJob makes sure a transcode is running for mediaID. The function is
|
// EnsureJob makes sure a transcode is running for mediaID from the start of
|
||||||
// non-blocking: it returns the playlist path immediately. The caller is
|
// the source. Prefer EnsureJobFrom when the player seeks into the middle.
|
||||||
// expected to poll until WaitReady reports true.
|
|
||||||
func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (string, error) {
|
func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (string, error) {
|
||||||
|
return t.EnsureJobFrom(ctx, mediaID, 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.
|
||||||
|
func (t *TranscoderService) EnsureJobFrom(ctx context.Context, mediaID string, startSec float64) (string, error) {
|
||||||
if !t.cfg.Transcoder.Enabled {
|
if !t.cfg.Transcoder.Enabled {
|
||||||
return "", ErrTranscodeDisabled
|
return "", ErrTranscodeDisabled
|
||||||
}
|
}
|
||||||
|
if startSec < 0 {
|
||||||
|
startSec = 0
|
||||||
|
}
|
||||||
m, err := t.repo.Media.FindByID(ctx, mediaID)
|
m, err := t.repo.Media.FindByID(ctx, mediaID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -106,11 +121,16 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
|||||||
if m == nil {
|
if m == nil {
|
||||||
return "", ErrMediaNotFound
|
return "", ErrMediaNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
if _, ok := t.jobs[mediaID]; ok {
|
if existing, ok := t.jobs[mediaID]; ok {
|
||||||
t.touchJobLocked(mediaID)
|
if sameHLSStart(existing.startSec, startSec) {
|
||||||
t.mu.Unlock()
|
t.touchJobLocked(mediaID)
|
||||||
return t.PlaylistPath(mediaID), nil
|
t.mu.Unlock()
|
||||||
|
return t.PlaylistPath(mediaID), nil
|
||||||
|
}
|
||||||
|
existing.cancel()
|
||||||
|
delete(t.jobs, mediaID)
|
||||||
}
|
}
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
|
|
||||||
@@ -118,27 +138,34 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
input.StartSec = startSec
|
||||||
|
t.maybeFillDuration(ctx, m, input)
|
||||||
if _, err := t.resolveFFmpegPath(); err != nil {
|
if _, err := t.resolveFFmpegPath(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
if _, ok := t.jobs[mediaID]; ok {
|
if existing, ok := t.jobs[mediaID]; ok {
|
||||||
t.touchJobLocked(mediaID)
|
if sameHLSStart(existing.startSec, startSec) {
|
||||||
t.mu.Unlock()
|
t.touchJobLocked(mediaID)
|
||||||
return t.PlaylistPath(mediaID), nil
|
t.mu.Unlock()
|
||||||
|
return t.PlaylistPath(mediaID), nil
|
||||||
|
}
|
||||||
|
existing.cancel()
|
||||||
|
delete(t.jobs, mediaID)
|
||||||
}
|
}
|
||||||
if max := t.maxConcurrent(); max > 0 && len(t.jobs) >= max {
|
if max := t.maxConcurrent(); max > 0 && len(t.jobs) >= max {
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
return "", ErrTranscodeBusy
|
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())
|
jobCtx, cancel := context.WithCancel(context.Background())
|
||||||
job := &hlsJob{
|
job := &hlsJob{
|
||||||
mediaID: mediaID,
|
mediaID: mediaID,
|
||||||
@@ -147,6 +174,7 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
|||||||
startedAt: time.Now(),
|
startedAt: time.Now(),
|
||||||
lastAccess: time.Now(),
|
lastAccess: time.Now(),
|
||||||
encoder: t.effectiveEncoder(),
|
encoder: t.effectiveEncoder(),
|
||||||
|
startSec: startSec,
|
||||||
}
|
}
|
||||||
t.jobs[mediaID] = job
|
t.jobs[mediaID] = job
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
@@ -156,6 +184,14 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri
|
|||||||
return t.PlaylistPath(mediaID), nil
|
return t.PlaylistPath(mediaID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sameHLSStart(a, b float64) bool {
|
||||||
|
const tol = 0.75
|
||||||
|
if a < b {
|
||||||
|
return b-a < tol
|
||||||
|
}
|
||||||
|
return a-b < tol
|
||||||
|
}
|
||||||
|
|
||||||
// SetStrmPlayTargetResolver wires STRM URL resolution so ffmpeg can transcode
|
// SetStrmPlayTargetResolver wires STRM URL resolution so ffmpeg can transcode
|
||||||
// remote .strm media (HTTP 直链 or local source path) after direct play fails.
|
// 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)) {
|
func (t *TranscoderService) SetStrmPlayTargetResolver(resolve func(ctx context.Context, raw string) (*StrmPlayResult, error)) {
|
||||||
@@ -165,6 +201,40 @@ func (t *TranscoderService) SetStrmPlayTargetResolver(resolve func(ctx context.C
|
|||||||
t.strmResolve = resolve
|
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) {
|
func (t *TranscoderService) resolveTranscodeInput(ctx context.Context, m *model.Media) (transcodeInput, error) {
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return transcodeInput{}, ErrMediaNotFound
|
return transcodeInput{}, ErrMediaNotFound
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type transcodeInput struct {
|
type transcodeInput struct {
|
||||||
Source string
|
Source string
|
||||||
Headers map[string]string
|
Headers map[string]string
|
||||||
|
StartSec float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type ffmpegArgSettings struct {
|
type ffmpegArgSettings struct {
|
||||||
@@ -125,6 +126,11 @@ func baseFFmpegArgs(preInput string, realtime bool) []string {
|
|||||||
|
|
||||||
func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpegArgSettings, video ffmpegVideoPlan) []string {
|
func appendInputAndVideoArgs(args []string, input transcodeInput, settings ffmpegArgSettings, video ffmpegVideoPlan) []string {
|
||||||
args = append(args, ffmpegHTTPInputArgs(input)...)
|
args = append(args, ffmpegHTTPInputArgs(input)...)
|
||||||
|
// Input seek (-ss before -i) lets mid-file HLS restarts jump without
|
||||||
|
// decoding everything before the click position.
|
||||||
|
if input.StartSec > 0.05 {
|
||||||
|
args = append(args, "-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)
|
args = append(args, "-i", input.Source, "-map", "0:v:0?", "-map", "0:a:0?", "-vf", video.filter, "-c:v", video.codec)
|
||||||
if settings.threads > 0 && video.codec == "libx264" {
|
if settings.threads > 0 && video.codec == "libx264" {
|
||||||
args = append(args, "-threads", strconv.Itoa(settings.threads))
|
args = append(args, "-threads", strconv.Itoa(settings.threads))
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ func (t *TranscoderService) runFFmpeg(ctx context.Context, job *hlsJob, input tr
|
|||||||
zap.String("media_id", job.mediaID),
|
zap.String("media_id", job.mediaID),
|
||||||
zap.String("encoder", job.encoder),
|
zap.String("encoder", job.encoder),
|
||||||
zap.String("source", input.Source),
|
zap.String("source", input.Source),
|
||||||
|
zap.Float64("start_sec", input.StartSec),
|
||||||
)
|
)
|
||||||
t.hub.Publish("transcode", map[string]any{
|
t.hub.Publish("transcode", map[string]any{
|
||||||
"media_id": job.mediaID,
|
"media_id": job.mediaID,
|
||||||
|
|||||||
@@ -193,3 +193,47 @@ func TestBuildFFmpegArgsHTTPInputReconnect(t *testing.T) {
|
|||||||
t.Fatalf("http flags must come before -i, args=%v", args)
|
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 -ss before -i, args=%v", args)
|
||||||
|
}
|
||||||
|
if args[idxSS+1] != "125.500" {
|
||||||
|
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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,8 +146,10 @@ export function streamURL(mediaId: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// hlsURL returns the m3u8 playlist URL fed into hls.js.
|
// hlsURL returns the m3u8 playlist URL fed into hls.js.
|
||||||
export function hlsURL(mediaId: string): string {
|
// startSec > 0 asks the server to (re)start ffmpeg from that source offset.
|
||||||
return `/api/hls/${encodeURIComponent(mediaId)}/index.m3u8?${tokenQuery()}${profileQuery()}`
|
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}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// imageURL converts a remote poster URL into a same-origin proxy URL so it
|
// imageURL converts a remote poster URL into a same-origin proxy URL so it
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ type PlayerControlsProps = {
|
|||||||
playlistOpen?: boolean
|
playlistOpen?: boolean
|
||||||
hasPlaylist?: boolean
|
hasPlaylist?: boolean
|
||||||
onTogglePlaylist?: () => void
|
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({
|
export function PlayerControls({
|
||||||
@@ -65,6 +71,9 @@ export function PlayerControls({
|
|||||||
playlistOpen = false,
|
playlistOpen = false,
|
||||||
hasPlaylist = false,
|
hasPlaylist = false,
|
||||||
onTogglePlaylist,
|
onTogglePlaylist,
|
||||||
|
knownDuration = 0,
|
||||||
|
streamOffset = 0,
|
||||||
|
onSeekAbsolute,
|
||||||
}: PlayerControlsProps) {
|
}: PlayerControlsProps) {
|
||||||
const video = () => videoRef.current
|
const video = () => videoRef.current
|
||||||
const container = () =>
|
const container = () =>
|
||||||
@@ -176,13 +185,14 @@ export function PlayerControls({
|
|||||||
}
|
}
|
||||||
const syncTime = () => {
|
const syncTime = () => {
|
||||||
if (!isScrubbingRef.current) {
|
if (!isScrubbingRef.current) {
|
||||||
setCurrentTime(el.currentTime)
|
setCurrentTime(streamOffset + el.currentTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const syncMeta = () => {
|
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) {
|
if (!isScrubbingRef.current) {
|
||||||
setCurrentTime(el.currentTime)
|
setCurrentTime(streamOffset + el.currentTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const syncVolume = () => {
|
const syncVolume = () => {
|
||||||
@@ -225,7 +235,14 @@ export function PlayerControls({
|
|||||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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(() => {
|
useEffect(() => {
|
||||||
@@ -248,12 +265,23 @@ export function PlayerControls({
|
|||||||
else el.pause()
|
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) => {
|
const handleSeekChange = (v: number) => {
|
||||||
setScrubValue(v)
|
setScrubValue(v)
|
||||||
setCurrentTime(v)
|
setCurrentTime(v)
|
||||||
const el = video()
|
if (!isScrubbing) {
|
||||||
if (el && !isScrubbing) {
|
applyAbsoluteSeek(v)
|
||||||
el.currentTime = v
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,11 +292,7 @@ export function PlayerControls({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSeekEnd = (v: number) => {
|
const handleSeekEnd = (v: number) => {
|
||||||
const el = video()
|
applyAbsoluteSeek(v)
|
||||||
if (el) {
|
|
||||||
el.currentTime = v
|
|
||||||
setCurrentTime(v)
|
|
||||||
}
|
|
||||||
setIsScrubbing(false)
|
setIsScrubbing(false)
|
||||||
setScrubValue(null)
|
setScrubValue(null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export function PlayerPage() {
|
|||||||
const [directOnly, setDirectOnly] = useState(false)
|
const [directOnly, setDirectOnly] = useState(false)
|
||||||
const [resumePosition, setResumePosition] = useState(0)
|
const [resumePosition, setResumePosition] = useState(0)
|
||||||
const [initialSeekDone, setInitialSeekDone] = useState(false)
|
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 初始值,用户在面板里实时调整。
|
// 弹幕控制:状态来自 /api/danmaku/config 初始值,用户在面板里实时调整。
|
||||||
const [danmakuOpen, setDanmakuOpen] = useState(false)
|
const [danmakuOpen, setDanmakuOpen] = useState(false)
|
||||||
@@ -187,6 +189,7 @@ export function PlayerPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMedia(null)
|
setMedia(null)
|
||||||
setLoadError('')
|
setLoadError('')
|
||||||
|
setHlsStartSec(0)
|
||||||
setDanmakuEpisodeId(null)
|
setDanmakuEpisodeId(null)
|
||||||
setDanmakuCandidates([])
|
setDanmakuCandidates([])
|
||||||
setDanmakuSearch(null)
|
setDanmakuSearch(null)
|
||||||
@@ -245,12 +248,22 @@ export function PlayerPage() {
|
|||||||
|
|
||||||
const video = ref.current
|
const video = ref.current
|
||||||
if (mode === 'hls') {
|
if (mode === 'hls') {
|
||||||
const url = hlsURL(media.id)
|
const url = hlsURL(media.id, hlsStartSec)
|
||||||
void import('hls.js').then(({ default: HlsCtor }) => {
|
void import('hls.js').then(({ default: HlsCtor }) => {
|
||||||
if (HlsCtor.isSupported()) {
|
if (HlsCtor.isSupported()) {
|
||||||
const hls = new HlsCtor({ enableWorker: true, lowLatencyMode: false })
|
const hls = new HlsCtor({ enableWorker: true, lowLatencyMode: false })
|
||||||
hls.loadSource(url)
|
hls.loadSource(url)
|
||||||
hls.attachMedia(video)
|
hls.attachMedia(video)
|
||||||
|
hls.on(HlsCtor.Events.MANIFEST_PARSED, () => {
|
||||||
|
// .strm 入库时常缺 duration;转码启动时会补探测,这里刷新一次给进度条总时长。
|
||||||
|
if ((media.duration_sec || 0) > 0) return
|
||||||
|
mediaAPI
|
||||||
|
.get(media.id)
|
||||||
|
.then((fresh) => {
|
||||||
|
if ((fresh.duration_sec || 0) > 0) setMedia(fresh)
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
})
|
||||||
hls.on(HlsCtor.Events.ERROR, (_, data) => {
|
hls.on(HlsCtor.Events.ERROR, (_, data) => {
|
||||||
if (data.fatal) {
|
if (data.fatal) {
|
||||||
setHlsUnavailable(true)
|
setHlsUnavailable(true)
|
||||||
@@ -283,8 +296,17 @@ export function PlayerPage() {
|
|||||||
}
|
}
|
||||||
void video.play().catch(() => undefined)
|
void video.play().catch(() => undefined)
|
||||||
}
|
}
|
||||||
return () => teardownHls(media.id, mode === 'hls')
|
return () => teardownHls()
|
||||||
}, [hlsUnavailable, media, mode, params, setParams, teardownHls])
|
}, [hlsUnavailable, hlsStartSec, media, mode, params, setParams, teardownHls])
|
||||||
|
|
||||||
|
// Stop the host ffmpeg job only when leaving HLS for this media (not on mid-file seek restarts).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!media || mode !== 'hls') return
|
||||||
|
const mediaId = media.id
|
||||||
|
return () => {
|
||||||
|
api.delete(`/hls/${encodeURIComponent(mediaId)}`).catch(() => undefined)
|
||||||
|
}
|
||||||
|
}, [media, mode])
|
||||||
|
|
||||||
// 自动拉取已有的播放进度并恢复播放位置
|
// 自动拉取已有的播放进度并恢复播放位置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -302,8 +324,21 @@ export function PlayerPage() {
|
|||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
useEffect(() => {
|
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
|
const video = ref.current
|
||||||
if (!video || !resumePosition || initialSeekDone) return
|
if (!video) return
|
||||||
const applyResume = () => {
|
const applyResume = () => {
|
||||||
if (resumePosition > 0 && Math.abs(video.currentTime - resumePosition) > 2) {
|
if (resumePosition > 0 && Math.abs(video.currentTime - resumePosition) > 2) {
|
||||||
video.currentTime = resumePosition
|
video.currentTime = resumePosition
|
||||||
@@ -320,18 +355,22 @@ export function PlayerPage() {
|
|||||||
video.addEventListener('loadedmetadata', applyResume, { once: true })
|
video.addEventListener('loadedmetadata', applyResume, { once: true })
|
||||||
return () => video.removeEventListener('loadedmetadata', applyResume)
|
return () => video.removeEventListener('loadedmetadata', applyResume)
|
||||||
}
|
}
|
||||||
}, [resumePosition, initialSeekDone])
|
}, [resumePosition, initialSeekDone, mode, hlsStartSec])
|
||||||
|
|
||||||
// Persist resume position every 10 seconds while playing, and immediately upon pause/unmount.
|
// Persist resume position every 10 seconds while playing, and immediately upon pause/unmount.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!media || !ref.current) return
|
if (!media || !ref.current) return
|
||||||
const video = ref.current
|
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 handler = () => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (now - lastSentRef.current < 10_000) return
|
if (now - lastSentRef.current < 10_000) return
|
||||||
lastSentRef.current = now
|
lastSentRef.current = now
|
||||||
const positionMs = Math.floor(video.currentTime * 1000)
|
const positionMs = absolutePositionMs()
|
||||||
const durationMs = Math.floor((video.duration || 0) * 1000)
|
const durationMs = absoluteDurationMs()
|
||||||
if (positionMs > 0) {
|
if (positionMs > 0) {
|
||||||
playbackAPI.recordProgress(media.id, positionMs, durationMs).catch(() => undefined)
|
playbackAPI.recordProgress(media.id, positionMs, durationMs).catch(() => undefined)
|
||||||
}
|
}
|
||||||
@@ -341,13 +380,13 @@ export function PlayerPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
video.removeEventListener('timeupdate', handler)
|
video.removeEventListener('timeupdate', handler)
|
||||||
video.removeEventListener('pause', handler)
|
video.removeEventListener('pause', handler)
|
||||||
const positionMs = Math.floor(video.currentTime * 1000)
|
const positionMs = absolutePositionMs()
|
||||||
const durationMs = Math.floor((video.duration || 0) * 1000)
|
const durationMs = absoluteDurationMs()
|
||||||
if (positionMs > 0 && media) {
|
if (positionMs > 0 && media) {
|
||||||
playbackAPI.recordProgress(media.id, positionMs, durationMs).catch(() => undefined)
|
playbackAPI.recordProgress(media.id, positionMs, durationMs).catch(() => undefined)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [media])
|
}, [media, mode, hlsStartSec])
|
||||||
|
|
||||||
// 加载剧集/播放列表
|
// 加载剧集/播放列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -521,11 +560,35 @@ export function PlayerPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const next = mode === 'hls' ? 'direct' : 'hls'
|
const next = mode === 'hls' ? 'direct' : 'hls'
|
||||||
|
if (next === 'hls') {
|
||||||
|
setHlsStartSec(0)
|
||||||
|
}
|
||||||
setMode(next)
|
setMode(next)
|
||||||
params.set('mode', next)
|
params.set('mode', next)
|
||||||
setParams(params, { replace: true })
|
setParams(params, { replace: true })
|
||||||
}, [isDirectStream, mode, params, setParams])
|
}, [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
|
||||||
|
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)) {
|
||||||
|
video.currentTime = local
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
setPlayerError('')
|
||||||
|
toast('正在从该位置重新转码…', { duration: 2000 })
|
||||||
|
setHlsStartSec(target)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
[hlsStartSec, mode],
|
||||||
|
)
|
||||||
|
|
||||||
// 用户切换字幕轨道:-1=关闭;记忆偏好,下次播放默认沿用。
|
// 用户切换字幕轨道:-1=关闭;记忆偏好,下次播放默认沿用。
|
||||||
const selectSubtitle = useCallback((index: number) => {
|
const selectSubtitle = useCallback((index: number) => {
|
||||||
setSubtitleIndex(index)
|
setSubtitleIndex(index)
|
||||||
@@ -614,6 +677,9 @@ export function PlayerPage() {
|
|||||||
playlistOpen={playlistOpen}
|
playlistOpen={playlistOpen}
|
||||||
hasPlaylist={playlistEpisodes.length > 0}
|
hasPlaylist={playlistEpisodes.length > 0}
|
||||||
onTogglePlaylist={togglePlaylistOpen}
|
onTogglePlaylist={togglePlaylistOpen}
|
||||||
|
knownDuration={media?.duration_sec || 0}
|
||||||
|
streamOffset={mode === 'hls' ? hlsStartSec : 0}
|
||||||
|
onSeekAbsolute={mode === 'hls' ? handleSeekAbsolute : undefined}
|
||||||
playlistPanel={
|
playlistPanel={
|
||||||
<PlayerPlaylistPanel
|
<PlayerPlaylistPanel
|
||||||
open={playlistOpen}
|
open={playlistOpen}
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ type PlayerVideoStageProps = {
|
|||||||
playlistOpen?: boolean
|
playlistOpen?: boolean
|
||||||
hasPlaylist?: boolean
|
hasPlaylist?: boolean
|
||||||
onTogglePlaylist?: () => void
|
onTogglePlaylist?: () => void
|
||||||
|
knownDuration?: number
|
||||||
|
streamOffset?: number
|
||||||
|
onSeekAbsolute?: (seconds: number) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlayerVideoStage({
|
export function PlayerVideoStage({
|
||||||
@@ -75,6 +78,9 @@ export function PlayerVideoStage({
|
|||||||
playlistOpen,
|
playlistOpen,
|
||||||
hasPlaylist,
|
hasPlaylist,
|
||||||
onTogglePlaylist,
|
onTogglePlaylist,
|
||||||
|
knownDuration,
|
||||||
|
streamOffset,
|
||||||
|
onSeekAbsolute,
|
||||||
}: PlayerVideoStageProps) {
|
}: PlayerVideoStageProps) {
|
||||||
const stageRef = useRef<HTMLDivElement>(null)
|
const stageRef = useRef<HTMLDivElement>(null)
|
||||||
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
const [videoRatio, setVideoRatio] = useState<number | null>(null)
|
||||||
@@ -319,6 +325,9 @@ export function PlayerVideoStage({
|
|||||||
playlistOpen={playlistOpen}
|
playlistOpen={playlistOpen}
|
||||||
hasPlaylist={hasPlaylist}
|
hasPlaylist={hasPlaylist}
|
||||||
onTogglePlaylist={onTogglePlaylist}
|
onTogglePlaylist={onTogglePlaylist}
|
||||||
|
knownDuration={knownDuration}
|
||||||
|
streamOffset={streamOffset}
|
||||||
|
onSeekAbsolute={onSeekAbsolute}
|
||||||
/>
|
/>
|
||||||
{danmakuPanel}
|
{danmakuPanel}
|
||||||
{playlistPanel}
|
{playlistPanel}
|
||||||
|
|||||||
Reference in New Issue
Block a user