Files
djk/internal/app/ui_playback_native_test.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)
}
}