Files
djk/internal/app/playback_plan_test.go
T

410 lines
17 KiB
Go

package app
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
"time"
)
func plannedFixture(t *testing.T, app *UIApp, episode, version int, mode string) playbackMediaPlan {
t.Helper()
app.mediaResources().settings.Enabled = true
body, _ := json.Marshal(map[string]any{"session": "fixture", "episode": episode, "version": version, "start": 1.25, "mode": mode,
"client": playbackClientCapabilities{MP4: true, NativeHLS: true, HlsJS: true, Video: []string{"h264"}, Audio: []string{"aac"}}})
request := httptest.NewRequest(http.MethodPost, "http://localhost/api/ui/playback/plan", bytes.NewReader(body))
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Origin", "http://localhost")
writer := httptest.NewRecorder()
app.handlePlaybackPlan(writer, viewerFixtureRequest(app, request))
if writer.Code != http.StatusOK {
t.Fatalf("plan: %d %s", writer.Code, writer.Body.String())
}
var plan playbackMediaPlan
if err := json.Unmarshal(writer.Body.Bytes(), &plan); err != nil {
t.Fatal(err)
}
return plan
}
func TestPlaybackPlanLocalMP4WithoutFFmpegAndVersionIsolation(t *testing.T) {
app, session := nativePlaybackFixture(t, "4", "320x180")
app.downloader.cfg.FFmpeg = filepath.Join(t.TempDir(), "unavailable-ffmpeg")
plan := plannedFixture(t, app, 1, 1, "auto")
if plan.Player != "mp4" || plan.Delivery != "local" || plan.Processing != "original" || plan.Duration < 3.9 || plan.Duration > 4.1 {
t.Fatalf("unexpected plan: %+v", plan)
}
body, err := os.ReadFile(session.tasks[0].OutPath)
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
method, span string
code, length int
}{{"GET", "bytes=0-15", 206, 16}, {"HEAD", "", 200, 0}, {"GET", "bytes=-16", 206, 16}, {"GET", "bytes=999999999-", 416, -1}} {
request := httptest.NewRequest(test.method, "http://localhost"+plan.URL, nil)
request.Header.Set("Range", test.span)
writer := httptest.NewRecorder()
app.handlePlaybackMediaAsset(writer, viewerFixtureRequest(app, request))
if writer.Code != test.code || test.length >= 0 && writer.Body.Len() != test.length {
t.Fatalf("media %s %s: %d %d", test.method, test.span, writer.Code, writer.Body.Len())
}
if test.span == "bytes=-16" && !bytes.Equal(writer.Body.Bytes(), body[len(body)-16:]) {
t.Fatal("tail range changed bytes")
}
}
if app.downloader.ffmpegInstaller != nil {
t.Fatal("original playback initialized FFmpeg")
}
other := app.browserViewers().acquire(viewerID("another-fixture-viewer"))
defer other.release()
writer := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "http://localhost"+plan.URL, nil)
app.handlePlaybackMediaAsset(writer, request.WithContext(context.WithValue(request.Context(), viewerContextKey{}, other)))
if writer.Code != http.StatusGone {
t.Fatal("another viewer obtained media", writer.Code)
}
old := session.media
plannedFixture(t, app, 2, 2, "auto")
writer = httptest.NewRecorder()
app.handlePlaybackMediaAsset(writer, viewerFixtureRequest(app, request))
if writer.Code != http.StatusGone || old.ctx.Err() == nil {
t.Fatal("switching episodes kept the previous media active")
}
}
func TestPlaybackPlanRemoteRangeRedirectAndFallback(t *testing.T) {
app, session := nativePlaybackFixture(t, "4", "320x180")
body, _ := os.ReadFile(session.tasks[0].OutPath)
var mediaRequests atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
mediaRequests.Add(1)
if request.Header.Get("X-Juku-Viewer") != "" || request.Header.Get("Cookie") != "" || request.Header.Get("Authorization") != "" {
t.Error("site credentials forwarded upstream")
}
writer.Header().Set("Content-Type", "video/mp4")
writer.Header().Set("ETag", `"fixture-v1"`)
if request.URL.Path == "/no-range.mp4" {
writer.Write(body)
return
}
http.ServeContent(writer, request, "fixture.mp4", time.Time{}, bytes.NewReader(body))
}))
defer upstream.Close()
app.downloader.client = upstream.Client()
session.downloadIDs = nil
session.tasks[0].Chapter.Source, session.tasks[0].Chapter.VideoURL = sourceHongguo, upstream.URL+"/fixture.mp4"
app.mediaResources().settings.DirectSources = []string{sourceHongguo}
plan := plannedFixture(t, app, 1, 1, "auto")
if plan.Delivery != "redirect" {
t.Fatalf("eligible MP4 did not redirect: %+v", plan)
}
before := mediaRequests.Load()
writer := httptest.NewRecorder()
app.handlePlaybackMediaAsset(writer, viewerFixtureRequest(app, httptest.NewRequest(http.MethodGet, "http://localhost"+plan.URL, nil)))
if writer.Code != http.StatusFound || writer.Header().Get("Location") != upstream.URL+"/fixture.mp4" || writer.Body.Len() != 0 || mediaRequests.Load() != before {
t.Fatal("redirect transferred media through the app")
}
plan = plannedFixture(t, app, 1, 2, "proxy")
if plan.Delivery != "proxy" || plan.Processing != "original" {
t.Fatal("direct failure selected encoding")
}
for _, span := range []string{"bytes=0-15", "bytes=-16", "bytes=999999999-"} {
request := httptest.NewRequest(http.MethodGet, "http://localhost"+plan.URL, nil)
request.Header.Set("Range", span)
writer = httptest.NewRecorder()
app.handlePlaybackMediaAsset(writer, viewerFixtureRequest(app, request))
if span == "bytes=999999999-" {
if writer.Code != 416 || writer.Header().Get("Content-Range") == "" {
t.Fatal("range error was hidden", writer.Code)
}
} else if writer.Code != 206 || writer.Body.Len() != 16 {
t.Fatal("remote range corrupted", writer.Code, writer.Body.Len())
}
}
session.tasks[0].Chapter.VideoURL = upstream.URL + "/no-range.mp4"
plan = plannedFixture(t, app, 1, 3, "auto")
request := httptest.NewRequest(http.MethodGet, "http://localhost"+plan.URL, nil)
request.Header.Set("Range", "bytes=9-15")
writer = httptest.NewRecorder()
app.handlePlaybackMediaAsset(writer, viewerFixtureRequest(app, request))
if plan.Delivery != "proxy" || writer.Code != 200 || writer.Header().Get("Accept-Ranges") != "" || !bytes.Equal(writer.Body.Bytes(), body) {
t.Fatal("proxy invented unsupported ranges")
}
if app.downloader.ffmpegInstaller != nil {
t.Fatal("raw remote media invoked FFmpeg")
}
}
func playbackDecodedFrames(t *testing.T, ffmpeg, address string) []string {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
output, err := exec.CommandContext(ctx, ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-threads", "1", "-i", address, "-map", "0:v:0", "-an", "-fps_mode", "passthrough", "-f", "framemd5", "-").Output()
if err != nil {
t.Fatal("decode fixture", err)
}
var frames []string
for _, line := range strings.Split(string(output), "\n") {
if line != "" && !strings.HasPrefix(line, "#") {
fields := strings.Split(line, ",")
frames = append(frames, strings.TrimSpace(fields[len(fields)-1]))
}
}
return frames
}
func TestPlaybackGeneratedInitializationPathMatchesPlaylist(t *testing.T) {
directory := filepath.Join(t.TempDir(), "media cache")
args := playbackGeneratedArgs(providerMedia{}, "input.mp4", directory, "remux")
playlist := args[len(args)-1]
var init, segment string
for i := 0; i+1 < len(args); i++ {
switch args[i] {
case "-hls_fmp4_init_filename":
init = args[i+1]
case "-hls_segment_filename":
segment = args[i+1]
}
}
if init != "init.mp4" || strings.ContainsAny(playlist+segment, "\\") || !strings.HasSuffix(segment, "/%06d.m4s") {
t.Fatalf("FFmpeg output paths are not portable: %q", args)
}
resolvedInit := playlist[:strings.LastIndex(playlist, "/")+1] + init
if resolvedInit != filepath.ToSlash(filepath.Join(directory, "init.mp4")) {
t.Fatalf("FFmpeg would write its initialization segment outside the media directory: %q", resolvedInit)
}
}
func TestPlaybackGeneratedPreservesLongGOPAndAudioOnlyVideo(t *testing.T) {
app, fixture := nativePlaybackFixture(t, "10", "320x180")
ffmpeg := app.downloader.cfg.FFmpeg
source := filepath.Join(t.TempDir(), "gop3.mp4")
command := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-i", fixture.tasks[0].OutPath, "-c:v", "libx264", "-preset", "veryfast", "-g", "90", "-keyint_min", "90", "-sc_threshold", "0", "-threads", "1", "-c:a", "copy", source)
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("fixture GOP: %v %s", err, output)
}
original := playbackDecodedFrames(t, ffmpeg, source)
for _, mode := range []string{"remux", "audio", "cenc"} {
t.Run(mode, func(t *testing.T) {
parent, cancel := context.WithCancel(context.Background())
defer cancel()
media := &playbackMediaSession{ctx: parent, cancel: cancel, local: source, plan: playbackMediaPlan{Delivery: "local"}}
processing := mode
if mode == "cenc" {
processing = "remux"
key := "000102030405060708090a0b0c0d0e0f"
media.media.CENCKey, _ = hex.DecodeString(key)
media.local = filepath.Join(t.TempDir(), "encrypted.mp4")
cmd := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-i", source, "-c", "copy", "-encryption_scheme", "cenc-aes-ctr", "-encryption_key", key, "-encryption_kid", "101112131415161718191a1b1c1d1e1f", media.local)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("CENC fixture: %v %s", err, output)
}
}
ctx, stop := context.WithTimeout(parent, 30*time.Second)
defer stop()
if err := app.generatePlayback(ctx, media, processing, "/media/", "", 0); err != nil {
media.Close()
t.Fatal(err)
}
defer media.Close()
stop()
if media.generated.ctx.Err() != nil {
t.Fatal("completed media inherited its preparation timeout")
}
completeDeadline := time.Now().Add(20 * time.Second)
for !media.generated.complete.Load() {
if time.Now().After(completeDeadline) {
t.Fatal("background remux did not complete")
}
time.Sleep(20 * time.Millisecond)
}
parts := media.generated.segments
if len(parts) != 4 || parts[0].Duration < 2.99 || parts[0].Duration > 3.01 || parts[1].Start != parts[0].Duration || !strings.Contains(media.generated.playlist, "#EXT-X-TARGETDURATION:3") {
t.Fatalf("invented fixed two-second timeline: %+v", parts)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { app.serveMediaSession(w, r, media) }))
defer server.Close()
copied := playbackDecodedFrames(t, ffmpeg, server.URL+media.plan.URL)
if len(original) != 300 || !reflect.DeepEqual(original, copied) {
t.Fatalf("video changed during %s: %d / %d frames", mode, len(original), len(copied))
}
directory := media.generated.directory
media.Close()
deadline := time.Now().Add(3 * time.Second)
for {
if _, err := os.Stat(directory); os.IsNotExist(err) {
break
}
if time.Now().After(deadline) {
t.Fatal("generated media survived close")
}
time.Sleep(10 * time.Millisecond)
}
})
}
app.mediaResources().mu.Lock()
defer app.mediaResources().mu.Unlock()
if app.mediaResources().cacheBytes != 0 {
t.Fatal("cache budget was not released")
}
}
func TestPlaybackOriginalHLSURIHeadersAndByteRanges(t *testing.T) {
app, fixture := nativePlaybackFixture(t, "2", "160x90")
var segmentRequests atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Cookie") != "" || r.Header.Get("X-Juku-Viewer") != "" {
t.Error("viewer leaked to HLS origin")
}
switch r.URL.Path {
case "/redirect.m3u8":
http.Redirect(w, r, "/real/variant.m3u8", 302)
case "/real/variant.m3u8":
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
io.WriteString(w, "#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:4\n#EXT-X-MEDIA-SEQUENCE:17\n#EXT-X-MAP:URI=\"init.mp4\"\n#EXT-X-KEY:METHOD=AES-128,URI=\"enc.key\",IV=0x00000000000000000000000000000011\n#EXTINF:3.123,\n#EXT-X-BYTERANGE:188@0\npart.bin\n#EXT-X-DISCONTINUITY\n#EXTINF:2.5,\n#EXT-X-BYTERANGE:188@188\npart.bin\n#EXT-X-ENDLIST\n")
case "/real/enc.key":
w.Write(bytes.Repeat([]byte{7}, 16))
case "/real/init.mp4":
w.Write([]byte("synthetic-init"))
case "/real/part.bin":
segmentRequests.Add(1)
http.ServeContent(w, r, "part", time.Time{}, bytes.NewReader(bytes.Repeat([]byte{0x47}, 376)))
default:
t.Error("wrong redirected HLS base", r.URL.Path)
w.WriteHeader(404)
}
}))
defer upstream.Close()
app.downloader.client = upstream.Client()
fixture.downloadIDs = nil
fixture.tasks[0].Chapter.Source, fixture.tasks[0].Chapter.VideoURL = sourceHongguo, upstream.URL+"/redirect.m3u8"
plan := plannedFixture(t, app, 1, 1, "auto")
if plan.Player != "hls" || plan.Processing != "original" || app.downloader.ffmpegInstaller != nil {
t.Fatalf("original HLS was processed: %+v", plan)
}
writer := httptest.NewRecorder()
app.handlePlaybackMediaAsset(writer, viewerFixtureRequest(app, httptest.NewRequest("GET", "http://localhost"+plan.URL, nil)))
playlist := writer.Body.String()
for _, expected := range []string{"#EXT-X-MEDIA-SEQUENCE:17", "#EXTINF:3.123,", "#EXT-X-BYTERANGE:188@188", "#EXT-X-DISCONTINUITY", "#EXT-X-ENDLIST", "IV=0x00000000000000000000000000000011"} {
if !strings.Contains(playlist, expected) {
t.Fatal("lost HLS semantics", expected, playlist)
}
}
if strings.Contains(playlist, upstream.URL) {
t.Fatal("gateway exposed origin URLs")
}
for _, line := range strings.Split(playlist, "\n") {
if !strings.HasPrefix(line, "/api/") {
continue
}
request := httptest.NewRequest("GET", "http://localhost"+line, nil)
request.Header.Set("Range", "bytes=188-375")
part := httptest.NewRecorder()
app.handlePlaybackMediaAsset(part, viewerFixtureRequest(app, request))
if part.Code != 206 || part.Body.Len() != 188 {
t.Fatal("HLS byte range changed", part.Code, part.Body.String())
}
}
if segmentRequests.Load() != 2 {
t.Fatal("unexpected HLS media fetch count", segmentRequests.Load())
}
}
func TestPlaybackResourceBudgetsCancellationAndPersistence(t *testing.T) {
app, admin := administratorFixture(t)
settings := defaultPlaybackSettings()
settings.Enabled = true
settings.MaxVideoTranscodes, settings.MaxMediaRequests = 0, 1
response := admin.request(t, http.MethodPost, "/api/ui/admin/playback", settings)
if response.Code != 200 {
t.Fatal(response.Code, response.Body.String())
}
restarted := &UIApp{cfg: app.cfg}
saved := restarted.mediaResources().snapshot()
if !saved.Enabled || saved.MaxVideoTranscodes != 0 {
t.Fatal("explicit playback settings did not persist")
}
resources := app.mediaResources()
if _, err := resources.acquire(context.Background(), "video", false); err == nil {
t.Fatal("disabled video processing was admitted")
}
release, err := resources.acquire(context.Background(), "media", false)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { _, err := resources.acquire(ctx, "media", false); done <- err }()
cancel()
if err := <-done; err != context.Canceled {
t.Fatal("queued media was not canceled", err)
}
if _, err := resources.acquire(context.Background(), "media", true); err == nil {
t.Fatal("background work displaced foreground playback")
}
release()
release()
resources.mu.Lock()
if resources.active["media"] != 0 || resources.waiting["media"] != 0 {
t.Fatal("resource lease leaked")
}
resources.mu.Unlock()
guest := newAccountTestBrowser(t, app)
if result := guest.request(t, http.MethodPost, "/api/ui/admin/playback", defaultPlaybackSettings()); result.Code != 403 {
t.Fatal("guest changed playback budgets", result.Code)
}
}
func TestEmbyOriginalMP4SignedRangeWithoutCookies(t *testing.T) {
app, fixture := nativePlaybackFixture(t, "4", "320x180")
app.mediaResources().settings.Enabled = true
key, err := app.embySigningKey(true)
if err != nil {
t.Fatal(err)
}
task := fixture.tasks[0]
query := url.Values{"id": {task.DramaID}, "chapter": {task.Chapter.ID}, "key": {embyToken(key, task.DramaID, task.Chapter.ID)}}
request := httptest.NewRequest("GET", "http://localhost/api/emby/stream.m3u8?"+query.Encode(), nil)
writer := httptest.NewRecorder()
app.handleEmbyStream(writer, request)
if writer.Code != 302 || !strings.Contains(writer.Header().Get("Location"), "file.mp4") {
t.Fatal("Emby did not receive original MP4", writer.Code, writer.Body.String())
}
address := writer.Header().Get("Location")
part := httptest.NewRequest("GET", "http://localhost"+address, nil)
part.Header.Set("Range", "bytes=0-15")
writer = httptest.NewRecorder()
app.routes().ServeHTTP(writer, part)
if writer.Code != 206 || writer.Body.Len() != 16 || app.downloader.ffmpegInstaller != nil {
t.Fatal("Emby original media was not served without FFmpeg", writer.Code, writer.Body.String())
}
parsed, _ := url.Parse(address)
changed := parsed.Query()
changed.Set("chapter", "forged-episode")
parsed.RawQuery = changed.Encode()
writer = httptest.NewRecorder()
app.routes().ServeHTTP(writer, httptest.NewRequest("GET", "http://localhost"+parsed.String(), nil))
if writer.Code != 403 {
t.Fatal("Emby resource accepted a changed signed identity")
}
first := address
writer = httptest.NewRecorder()
app.handleEmbyStream(writer, request)
if writer.Header().Get("Location") != first {
t.Fatal("repeated Emby playback did not share prepared media")
}
}