mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad427fc0d2 | |||
| c489374b61 | |||
| 5a47e6f52d | |||
| c4b8114115 | |||
| 0e592105c4 | |||
| f20c9c3234 |
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"ssh": {
|
||||
"command": "cmd.exe",
|
||||
"args": [
|
||||
"/c",
|
||||
"npx",
|
||||
"-y",
|
||||
"@aiondadotcom/mcp-ssh"
|
||||
],
|
||||
"env": {
|
||||
"ProgramData": "C:\\ProgramData"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user