|
|
|
@@ -11,9 +11,12 @@ import (
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"sync/atomic"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const playbackProgressiveSegments = 4
|
|
|
|
|
|
|
|
|
|
type playbackSegmentIndex struct {
|
|
|
|
|
Name string
|
|
|
|
|
Start float64
|
|
|
|
@@ -28,14 +31,22 @@ type playbackGenerated struct {
|
|
|
|
|
query string
|
|
|
|
|
playlist string
|
|
|
|
|
segments []playbackSegmentIndex
|
|
|
|
|
lastEnd float64
|
|
|
|
|
files map[string]bool
|
|
|
|
|
bytes int64
|
|
|
|
|
resources *playbackResources
|
|
|
|
|
done chan struct{}
|
|
|
|
|
once sync.Once
|
|
|
|
|
doneOnce sync.Once
|
|
|
|
|
closeOnce sync.Once
|
|
|
|
|
complete atomic.Bool
|
|
|
|
|
failed atomic.Bool
|
|
|
|
|
mu sync.RWMutex
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (generated *playbackGenerated) finish() {
|
|
|
|
|
generated.doneOnce.Do(func() { close(generated.done) })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func playbackGeneratedArgs(media providerMedia, input, directory, processing string) []string {
|
|
|
|
|
args := playbackInputArgs(media, input, 0)
|
|
|
|
|
if processing == "audio" {
|
|
|
|
@@ -44,11 +55,11 @@ func playbackGeneratedArgs(media providerMedia, input, directory, processing str
|
|
|
|
|
args = append(args, "-c", "copy")
|
|
|
|
|
}
|
|
|
|
|
return append(args, "-avoid_negative_ts", "make_zero", "-f", "hls", "-hls_time", "2", "-hls_list_size", "0",
|
|
|
|
|
"-hls_playlist_type", "vod", "-hls_segment_type", "fmp4", "-hls_fmp4_init_filename", "init.mp4",
|
|
|
|
|
"-hls_playlist_type", "event", "-hls_segment_type", "fmp4", "-hls_fmp4_init_filename", "init.mp4",
|
|
|
|
|
"-hls_flags", "temp_file", "-hls_segment_filename", filepath.ToSlash(filepath.Join(directory, "%06d.m4s")), filepath.ToSlash(filepath.Join(directory, "index.m3u8")))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (app *UIApp) generatePlayback(ctx context.Context, media *playbackMediaSession, processing, base, query string) error {
|
|
|
|
|
func (app *UIApp) generatePlayback(ctx context.Context, media *playbackMediaSession, processing, base, query string, startSeconds float64) error {
|
|
|
|
|
ffmpeg, err := app.downloader.ensureFFmpeg(ctx)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
@@ -57,26 +68,25 @@ func (app *UIApp) generatePlayback(ctx context.Context, media *playbackMediaSess
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
defer release()
|
|
|
|
|
directory, err := os.MkdirTemp("", "juku-playback-media-")
|
|
|
|
|
if err != nil {
|
|
|
|
|
release()
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
jobCtx, cancel := context.WithCancel(media.ctx)
|
|
|
|
|
stop := context.AfterFunc(ctx, cancel)
|
|
|
|
|
defer stop()
|
|
|
|
|
generated := &playbackGenerated{ctx: jobCtx, cancel: cancel, directory: directory, base: base, query: query,
|
|
|
|
|
resources: app.mediaResources(), done: make(chan struct{}), files: make(map[string]bool)}
|
|
|
|
|
media.generated = generated
|
|
|
|
|
defer close(generated.done)
|
|
|
|
|
input := media.local
|
|
|
|
|
var proxy *hlsProxy
|
|
|
|
|
if input == "" {
|
|
|
|
|
proxy, err = app.downloader.newHLSProxy(jobCtx, media.media, media.key)
|
|
|
|
|
if err != nil {
|
|
|
|
|
generated.finish()
|
|
|
|
|
cancel()
|
|
|
|
|
release()
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
defer proxy.Close()
|
|
|
|
|
proxy.acquire = func(ctx context.Context) (func(), error) { return app.mediaResources().acquire(ctx, "media", false) }
|
|
|
|
|
input = proxy.root
|
|
|
|
|
}
|
|
|
|
@@ -84,47 +94,110 @@ func (app *UIApp) generatePlayback(ctx context.Context, media *playbackMediaSess
|
|
|
|
|
log := &playbackLog{text: cappedStringWriter{limit: 64 * 1024}, ready: make(chan struct{})}
|
|
|
|
|
command.Stderr = log
|
|
|
|
|
if err := command.Start(); err != nil {
|
|
|
|
|
generated.finish()
|
|
|
|
|
cancel()
|
|
|
|
|
release()
|
|
|
|
|
return fmt.Errorf("无法启动媒体封装:%w", err)
|
|
|
|
|
}
|
|
|
|
|
done := make(chan error, 1)
|
|
|
|
|
go func() { done <- command.Wait() }()
|
|
|
|
|
ticker := time.NewTicker(50 * time.Millisecond)
|
|
|
|
|
go func() { // 后台:持续刷新渐进式播放列表,等 ffmpeg 收尾后补 ENDLIST 并释放转封装资源
|
|
|
|
|
defer generated.finish()
|
|
|
|
|
defer release()
|
|
|
|
|
ticker := time.NewTicker(300 * time.Millisecond)
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case waitErr := <-done:
|
|
|
|
|
if waitErr != nil {
|
|
|
|
|
if jobCtx.Err() == nil {
|
|
|
|
|
generated.failed.Store(true)
|
|
|
|
|
app.downloader.recordDiagnostic(diagnosticEvent{Level: "warn", Event: "playback.generated.failed",
|
|
|
|
|
Message: "媒体封装中断:" + fmt.Sprint(waitErr)})
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
_ = generated.accountCache()
|
|
|
|
|
if data, err := os.ReadFile(filepath.Join(generated.directory, "index.m3u8")); err == nil {
|
|
|
|
|
_ = generated.index(string(data))
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
case <-jobCtx.Done():
|
|
|
|
|
<-done
|
|
|
|
|
return
|
|
|
|
|
case <-ticker.C:
|
|
|
|
|
if err := generated.accountCache(); err != nil {
|
|
|
|
|
generated.failed.Store(true)
|
|
|
|
|
cancel()
|
|
|
|
|
<-done
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if data, err := os.ReadFile(filepath.Join(generated.directory, "index.m3u8")); err == nil {
|
|
|
|
|
_ = generated.index(string(data))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
// 前台:前几个分片就绪(并覆盖起播位置)即返回计划,剩余部分边播边转
|
|
|
|
|
ticker := time.NewTicker(80 * time.Millisecond)
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case err := <-done:
|
|
|
|
|
if err != nil {
|
|
|
|
|
return playbackStreamError(jobCtx, proxy, log, media.media, err, nil)
|
|
|
|
|
case <-generated.done:
|
|
|
|
|
if ctx.Err() != nil {
|
|
|
|
|
return ctx.Err()
|
|
|
|
|
}
|
|
|
|
|
if err = generated.accountCache(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
generated.mu.RLock()
|
|
|
|
|
complete, failed := generated.complete.Load(), generated.failed.Load()
|
|
|
|
|
segmentCount := len(generated.segments)
|
|
|
|
|
lastEnd := generated.lastEnd
|
|
|
|
|
generated.mu.RUnlock()
|
|
|
|
|
if failed || !complete || segmentCount == 0 {
|
|
|
|
|
return errors.New("媒体封装未能完成")
|
|
|
|
|
}
|
|
|
|
|
body, err := os.ReadFile(filepath.Join(directory, "index.m3u8"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if err := generated.index(string(body)); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
media.plan.URL = generated.assetURL("index.m3u8")
|
|
|
|
|
media.plan.Player, media.plan.MIME, media.plan.Processing = "hls", "application/vnd.apple.mpegurl", processing
|
|
|
|
|
media.plan.Duration = generated.segments[len(generated.segments)-1].Start + generated.segments[len(generated.segments)-1].Duration
|
|
|
|
|
media.plan.SeekEnd = media.plan.Duration
|
|
|
|
|
generated.applyPlan(media, processing, lastEnd)
|
|
|
|
|
return nil
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
cancel()
|
|
|
|
|
<-generated.done
|
|
|
|
|
return ctx.Err()
|
|
|
|
|
case <-ticker.C:
|
|
|
|
|
if err := generated.accountCache(); err != nil {
|
|
|
|
|
cancel()
|
|
|
|
|
<-done
|
|
|
|
|
<-generated.done
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
cancel()
|
|
|
|
|
<-done
|
|
|
|
|
return ctx.Err()
|
|
|
|
|
data, err := os.ReadFile(filepath.Join(generated.directory, "index.m3u8"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
body := string(data)
|
|
|
|
|
if strings.Count(body, "#EXTINF") < playbackProgressiveSegments {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if err := generated.index(body); err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
generated.mu.RLock()
|
|
|
|
|
lastEnd := generated.lastEnd
|
|
|
|
|
generated.mu.RUnlock()
|
|
|
|
|
if lastEnd < startSeconds+6 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
generated.applyPlan(media, processing, lastEnd)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (generated *playbackGenerated) applyPlan(media *playbackMediaSession, processing string, lastEnd float64) {
|
|
|
|
|
media.plan.URL = generated.assetURL("index.m3u8")
|
|
|
|
|
media.plan.Player, media.plan.MIME, media.plan.Processing = "hls", "application/vnd.apple.mpegurl", processing
|
|
|
|
|
if media.plan.Duration <= 0 {
|
|
|
|
|
media.plan.Duration, media.plan.SeekEnd = lastEnd, lastEnd
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (generated *playbackGenerated) accountCache() error {
|
|
|
|
|
entries, err := os.ReadDir(generated.directory)
|
|
|
|
|
if err != nil {
|
|
|
|
@@ -164,50 +237,63 @@ func (generated *playbackGenerated) assetURL(name string) string {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (generated *playbackGenerated) index(playlist string) error {
|
|
|
|
|
if len(playlist) > 2<<20 || !strings.HasPrefix(playlist, "#EXTM3U") || !strings.Contains(playlist, "#EXT-X-ENDLIST") {
|
|
|
|
|
return errors.New("媒体封装尚未完成,不能生成完整播放索引")
|
|
|
|
|
complete := strings.Contains(playlist, "#EXT-X-ENDLIST")
|
|
|
|
|
if len(playlist) > 2<<20 || !strings.HasPrefix(playlist, "#EXTM3U") {
|
|
|
|
|
return errors.New("媒体封装尚未就绪")
|
|
|
|
|
}
|
|
|
|
|
lines := strings.Split(playlist, "\n")
|
|
|
|
|
var start, duration, longest float64
|
|
|
|
|
segments := make([]playbackSegmentIndex, 0, 64)
|
|
|
|
|
files := make(map[string]bool, 64)
|
|
|
|
|
var start, duration float64
|
|
|
|
|
validName := func(name string) bool {
|
|
|
|
|
return name != "" && filepath.Base(name) == name && !strings.ContainsAny(name, "\\?\x00") && (strings.HasSuffix(name, ".mp4") || strings.HasSuffix(name, ".m4s"))
|
|
|
|
|
}
|
|
|
|
|
for i, line := range lines {
|
|
|
|
|
if strings.HasPrefix(line, "#EXTINF:") {
|
|
|
|
|
value, _, _ := strings.Cut(strings.TrimPrefix(line, "#EXTINF:"), ",")
|
|
|
|
|
var err error
|
|
|
|
|
duration, err = strconv.ParseFloat(value, 64)
|
|
|
|
|
if err != nil || math.IsNaN(duration) || math.IsInf(duration, 0) || duration <= 0 || duration > 3600 {
|
|
|
|
|
parsed, err := strconv.ParseFloat(value, 64)
|
|
|
|
|
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed <= 0 || parsed > 3600 {
|
|
|
|
|
return errors.New("媒体分片时长无效")
|
|
|
|
|
}
|
|
|
|
|
longest = math.Max(longest, duration)
|
|
|
|
|
duration = parsed
|
|
|
|
|
} else if strings.HasPrefix(line, "#EXT-X-MAP:") {
|
|
|
|
|
matches := hlsURIAttribute.FindStringSubmatch(line)
|
|
|
|
|
if len(matches) != 2 || !validName(matches[1]) {
|
|
|
|
|
return errors.New("媒体初始化片段无效")
|
|
|
|
|
}
|
|
|
|
|
generated.files[matches[1]] = true
|
|
|
|
|
files[matches[1]] = true
|
|
|
|
|
lines[i] = strings.Replace(line, matches[0], `URI="`+generated.assetURL(matches[1])+`"`, 1)
|
|
|
|
|
} else if line != "" && !strings.HasPrefix(line, "#") {
|
|
|
|
|
if duration <= 0 || !validName(line) || len(generated.segments) >= 16384 || start+duration > 24*60*60 {
|
|
|
|
|
if duration <= 0 || !validName(line) || len(segments) >= 16384 || start+duration > 24*60*60 {
|
|
|
|
|
return errors.New("媒体分片索引无效")
|
|
|
|
|
}
|
|
|
|
|
generated.files[line] = true
|
|
|
|
|
generated.segments = append(generated.segments, playbackSegmentIndex{Name: line, Start: start, Duration: duration})
|
|
|
|
|
files[line] = true
|
|
|
|
|
segments = append(segments, playbackSegmentIndex{Name: line, Start: start, Duration: duration})
|
|
|
|
|
start += duration
|
|
|
|
|
duration = 0
|
|
|
|
|
lines[i] = generated.assetURL(line)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(generated.segments) == 0 || duration != 0 {
|
|
|
|
|
if !complete && duration > 0 {
|
|
|
|
|
duration = 0
|
|
|
|
|
}
|
|
|
|
|
if len(segments) == 0 || (complete && duration != 0) {
|
|
|
|
|
return errors.New("媒体播放索引没有完整分片")
|
|
|
|
|
}
|
|
|
|
|
if !complete && len(segments) < len(generated.segments) {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
for i, line := range lines {
|
|
|
|
|
if strings.HasPrefix(line, "#EXT-X-TARGETDURATION:") {
|
|
|
|
|
var longest float64
|
|
|
|
|
for _, segment := range segments {
|
|
|
|
|
longest = math.Max(longest, segment.Duration)
|
|
|
|
|
}
|
|
|
|
|
lines[i] = "#EXT-X-TARGETDURATION:" + strconv.Itoa(int(math.Ceil(longest)))
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for name := range generated.files {
|
|
|
|
|
for name := range files {
|
|
|
|
|
info, err := os.Stat(filepath.Join(generated.directory, name))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("媒体分片未完整生成(%s):%w", name, err)
|
|
|
|
@@ -219,25 +305,41 @@ func (generated *playbackGenerated) index(playlist string) error {
|
|
|
|
|
return fmt.Errorf("媒体分片未完整生成(%s 为空文件)", name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
generated.mu.Lock()
|
|
|
|
|
generated.segments = segments
|
|
|
|
|
generated.files = files
|
|
|
|
|
generated.lastEnd = start
|
|
|
|
|
generated.playlist = strings.Join(lines, "\n")
|
|
|
|
|
generated.mu.Unlock()
|
|
|
|
|
generated.complete.Store(complete)
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (generated *playbackGenerated) Close() {
|
|
|
|
|
generated.once.Do(func() {
|
|
|
|
|
generated.closeOnce.Do(func() {
|
|
|
|
|
generated.cancel()
|
|
|
|
|
go func() {
|
|
|
|
|
<-generated.done
|
|
|
|
|
generated.mu.Lock()
|
|
|
|
|
defer generated.mu.Unlock()
|
|
|
|
|
_ = os.RemoveAll(generated.directory)
|
|
|
|
|
removeMediaDirectory(generated.directory)
|
|
|
|
|
generated.resources.mu.Lock()
|
|
|
|
|
defer generated.resources.mu.Unlock()
|
|
|
|
|
generated.resources.cacheBytes -= generated.bytes
|
|
|
|
|
generated.resources.mu.Unlock()
|
|
|
|
|
}()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func removeMediaDirectory(directory string) {
|
|
|
|
|
for attempt := 0; attempt < 30; attempt++ {
|
|
|
|
|
err := os.RemoveAll(directory)
|
|
|
|
|
if err == nil {
|
|
|
|
|
if _, statErr := os.Stat(directory); os.IsNotExist(statErr) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (generated *playbackGenerated) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
|
|
|
|
generated.mu.RLock()
|
|
|
|
|
defer generated.mu.RUnlock()
|
|
|
|
@@ -247,6 +349,10 @@ func (generated *playbackGenerated) ServeHTTP(writer http.ResponseWriter, reques
|
|
|
|
|
}
|
|
|
|
|
name := strings.TrimPrefix(request.URL.Path, generated.base)
|
|
|
|
|
if name == "index.m3u8" {
|
|
|
|
|
if generated.failed.Load() && !generated.complete.Load() {
|
|
|
|
|
writeJSON(writer, http.StatusBadGateway, map[string]string{"error": "媒体封装中断,请重新播放"})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writer.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
|
|
|
|
http.ServeContent(writer, request, name, time.Time{}, strings.NewReader(generated.playlist))
|
|
|
|
|
return
|
|
|
|
|