224 lines
10 KiB
Go
224 lines
10 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func nativePlaybackFixture(t *testing.T, duration, size string) (*UIApp, *playbackSession) {
|
|
t.Helper()
|
|
ffmpeg, err := exec.LookPath("ffmpeg")
|
|
if err != nil {
|
|
t.Skip("local FFmpeg is unavailable")
|
|
}
|
|
video := filepath.Join(t.TempDir(), "generated.mp4")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
command := exec.CommandContext(ctx, ffmpeg, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size="+size+":rate=30:duration="+duration, "-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=48000", "-t", duration, "-c:v", "libx264", "-preset", "ultrafast", "-threads", "1", "-c:a", "aac", "-movflags", "+faststart", video)
|
|
if output, err := command.CombinedOutput(); err != nil {
|
|
t.Fatalf("generate local fixture: %v %s", err, output)
|
|
}
|
|
app, session := prefetchFixtureApp(t)
|
|
app.downloader.cfg.FFmpeg = ffmpeg
|
|
app.cfg = app.downloader.cfg
|
|
app.tasks = make(map[string]*UITask)
|
|
session.downloadIDs = []string{"one", "two", "three"}
|
|
session.prepared = make(map[int]bool)
|
|
for index, id := range session.downloadIDs {
|
|
task := Task{DramaID: "hongguo:7000000000000000001", DramaTitle: "本地生成播放样本", Index: index + 1, Total: 3, OutPath: video, Chapter: Chapter{ID: "hongguo:7000000000000000001:" + strconv.Itoa(index+1), CurrentEpisode: rawEpisode(index + 1)}}
|
|
session.tasks[index] = task
|
|
session.prepared[index+1] = true
|
|
app.tasks[id] = &UITask{ID: id, DramaID: task.DramaID, Status: uiStatusSuccess, Path: video, Source: task}
|
|
}
|
|
return app, session
|
|
}
|
|
|
|
func nativeOpenFixture(t *testing.T, app *UIApp, episode int, offset float64, version int) map[string]json.RawMessage {
|
|
t.Helper()
|
|
request := httptest.NewRequest(http.MethodPost, "http://localhost/api/ui/playback/hls/open", strings.NewReader(fmt.Sprintf(`{"session":"fixture","episode":%d,"start":%f,"version":%d}`, episode, offset, version)))
|
|
request.Header.Set("Content-Type", "application/json")
|
|
writer := httptest.NewRecorder()
|
|
app.handlePlaybackNativeOpen(writer, viewerFixtureRequest(app, request))
|
|
if writer.Code != http.StatusOK {
|
|
t.Fatalf("native playback open: %d %s", writer.Code, writer.Body.String())
|
|
}
|
|
var result map[string]json.RawMessage
|
|
if err := json.Unmarshal(writer.Body.Bytes(), &result); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func TestPlaybackNativeHLSSeeksRangesPrefetchAndCleanup(t *testing.T) {
|
|
app, session := nativePlaybackFixture(t, "30", "320x180")
|
|
opened := nativeOpenFixture(t, app, 1, 0, 1)
|
|
var address string
|
|
if json.Unmarshal(opened["url"], &address) != nil || !strings.Contains(address, "index.m3u8") {
|
|
t.Fatal("native route did not return an HLS address")
|
|
}
|
|
cache := session.native
|
|
writer := httptest.NewRecorder()
|
|
app.handlePlaybackNativeAsset(writer, viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, "http://localhost"+address, nil)))
|
|
if writer.Code != http.StatusOK || strings.Count(writer.Body.String(), "#EXTINF:") != 15 || !strings.Contains(writer.Body.String(), "#EXT-X-ENDLIST") || strings.Contains(writer.Body.String(), "generated.mp4") {
|
|
t.Fatal("native playlist is not a complete, private VOD timeline", writer.Body.String())
|
|
}
|
|
read := func(segment int, method, header string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
request := httptest.NewRequest(method, fmt.Sprintf("http://localhost/api/ui/playback/hls/segment.ts?session=fixture&run=%d&segment=%d", session.run, segment), nil)
|
|
if header != "" {
|
|
request.Header.Set("Range", header)
|
|
}
|
|
result := httptest.NewRecorder()
|
|
app.handlePlaybackNativeAsset(result, viewerFixtureRequest(app, request))
|
|
return result
|
|
}
|
|
first := read(0, http.MethodGet, "bytes=0-187")
|
|
if first.Code != http.StatusPartialContent || first.Body.Len() != 188 || first.Body.Bytes()[0] != 0x47 || first.Header().Get("Content-Type") != "video/mp2t" {
|
|
t.Fatal("HLS byte ranges did not return a transport-stream packet", first.Code, first.Body.Len())
|
|
}
|
|
last := read(14, http.MethodGet, "")
|
|
if last.Code != http.StatusOK || last.Body.Len() < 188 || read(0, http.MethodHead, "").Body.Len() != 0 {
|
|
t.Fatal("seeking across encoding batches or HEAD failed", last.Code, last.Body.String())
|
|
}
|
|
state, _ := app.playbackStatus("fixture", false)
|
|
if state.State != "ended" || state.Duration < 29.9 || state.Duration > 30.1 {
|
|
t.Fatalf("native run lost its full duration: %+v", state)
|
|
}
|
|
result := prefetchRequest(app, fmt.Sprintf(`{"session":"fixture","episode":2,"run":%d,"version":1}`, session.run))
|
|
if result.Code != http.StatusAccepted {
|
|
t.Fatal("native next episode was not accepted", result.Body.String())
|
|
}
|
|
prefetch := session.prefetch
|
|
select {
|
|
case <-prefetch.done:
|
|
case <-time.After(30 * time.Second):
|
|
t.Fatal("native prefetch did not finish its bounded batch")
|
|
}
|
|
if prefetch.view().State != "partial" {
|
|
t.Fatal("native prefetch must describe partial caching accurately", prefetch.view())
|
|
}
|
|
opened = nativeOpenFixture(t, app, 2, 0, 2)
|
|
if string(opened["prefetched"]) != "true" || session.native != prefetch.native {
|
|
t.Fatal("native playback did not reuse the prepared next episode")
|
|
}
|
|
writer = httptest.NewRecorder()
|
|
app.handlePlaybackNativeAsset(writer, viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, "http://localhost"+address, nil)))
|
|
if writer.Code != http.StatusGone || cache.ctx.Err() == nil {
|
|
t.Fatal("previous HLS run stayed active after switching episodes")
|
|
}
|
|
active := session.native
|
|
active.mu.Lock()
|
|
directory := active.directory
|
|
active.mu.Unlock()
|
|
app.closePlayback("fixture")
|
|
active.workers.Wait()
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for {
|
|
_, err := os.Stat(directory)
|
|
if os.IsNotExist(err) {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("native temporary files survived session close")
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
}
|
|
|
|
func TestPlaybackKeeps1080pAndNativeResumeTimeline(t *testing.T) {
|
|
app, session := nativePlaybackFixture(t, "4", "1080x1920")
|
|
writer := httptest.NewRecorder()
|
|
app.handlePlaybackStream(writer, viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, "http://localhost/api/ui/playback/stream?session=fixture&episode=1", nil)))
|
|
if writer.Code != http.StatusOK || !bytes.Contains(writer.Body.Bytes(), []byte("moof")) {
|
|
t.Fatal("generated 1080p stream failed", writer.Code)
|
|
}
|
|
path := filepath.Join(t.TempDir(), "encoded.mp4")
|
|
if err := os.WriteFile(path, writer.Body.Bytes(), 0600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
command := exec.Command(app.downloader.cfg.FFmpeg, "-hide_banner", "-i", path, "-t", "0", "-f", "null", "-")
|
|
output, err := command.CombinedOutput()
|
|
if err != nil || !strings.Contains(string(output), "1080x1920") || writer.Header().Get("X-Playback-MIME") != `video/mp4; codecs="avc1.42C028, mp4a.40.2"` {
|
|
t.Fatalf("1080p was downscaled or codec metadata was wrong: %v %s %s", err, writer.Header().Get("X-Playback-MIME"), output)
|
|
}
|
|
opened := nativeOpenFixture(t, app, 1, 2.5, 1)
|
|
if string(opened["duration"]) != "4" {
|
|
t.Fatal("native resume lost the full episode duration", string(opened["duration"]))
|
|
}
|
|
var address string
|
|
_ = json.Unmarshal(opened["url"], &address)
|
|
writer = httptest.NewRecorder()
|
|
app.handlePlaybackNativeAsset(writer, viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, "http://localhost"+address, nil)))
|
|
if !strings.Contains(writer.Body.String(), "TIME-OFFSET=2.500") || strings.Count(writer.Body.String(), "#EXTINF:") != 2 || session.historyRuns[session.run].duration != 4 {
|
|
t.Fatal("native seek or history used a shortened timeline")
|
|
}
|
|
}
|
|
|
|
func TestPlaybackNativeDecodesAcrossBatches(t *testing.T) {
|
|
app, session := nativePlaybackFixture(t, "50.1", "320x180")
|
|
opened := nativeOpenFixture(t, app, 1, 0, 1)
|
|
var address string
|
|
if err := json.Unmarshal(opened["url"], &address); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mux := http.NewServeMux()
|
|
app.registerPlaybackRoutes(mux)
|
|
server := httptest.NewServer(viewerFixtureHandler(app, mux))
|
|
defer server.Close()
|
|
defer app.closePlayback("fixture")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
|
|
defer cancel()
|
|
command := exec.CommandContext(ctx, app.downloader.cfg.FFmpeg, "-hide_banner", "-loglevel", "warning", "-xerror", "-nostdin", "-i", server.URL+address, "-map", "0:v:0", "-map", "0:a:0", "-progress", "pipe:1", "-f", "null", "-")
|
|
var progress, diagnostic bytes.Buffer
|
|
command.Stdout, command.Stderr = &progress, &diagnostic
|
|
if err := command.Run(); err != nil {
|
|
t.Fatalf("decode complete HLS timeline: %v\n%s\n%s", err, diagnostic.String(), progress.String())
|
|
}
|
|
values := make(map[string]string)
|
|
for _, line := range strings.Split(progress.String(), "\n") {
|
|
key, value, ok := strings.Cut(line, "=")
|
|
if ok {
|
|
values[key] = value
|
|
}
|
|
}
|
|
frames, _ := strconv.Atoi(strings.TrimSpace(values["frame"]))
|
|
micros, _ := strconv.Atoi(values["out_time_us"])
|
|
if values["progress"] != "end" || frames != 1503 || micros < 50_000_000 || micros > 50_250_000 {
|
|
t.Fatalf("HLS batches lost frames or their shared timeline: %v\n%s", values, diagnostic.String())
|
|
}
|
|
if state, err := session.native.state(); state != "ended" || err != nil {
|
|
t.Fatalf("complete HLS decode left an invalid state: %s %v", state, err)
|
|
}
|
|
}
|
|
|
|
func TestPlaybackNativeBoundsMemoryAndCancelsWaiters(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cache := &playbackNative{ctx: ctx, cancel: cancel, changed: make(chan struct{}), segments: make(map[int][]byte), wanted: 40}
|
|
defer cache.Close()
|
|
data := make([]byte, 2*1024*1024)
|
|
for index := 0; index < 70; index++ {
|
|
cache.segments[index] = data
|
|
cache.bytes += len(data)
|
|
}
|
|
cache.evictLocked(69)
|
|
if cache.bytes > playbackNativeCacheBytes || len(cache.segments[40]) == 0 || len(cache.segments[69]) == 0 {
|
|
t.Fatal("native cache exceeded its bound or evicted the current segment")
|
|
}
|
|
canceled, stop := context.WithCancel(context.Background())
|
|
stop()
|
|
if _, err := cache.segment(canceled, 100); err != context.Canceled || cache.job != nil {
|
|
t.Fatal("canceled native request started more work", err)
|
|
}
|
|
}
|