Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Restream brings your IPTV subscription to the browser. Host it on your own computer or server to watch live TV, movies, and shows from a Stalker/MAG provider, with a programme guide, search, watch progress, and a shared library cache. Use it on desktop or mobile, with optional OIDC sign-in through your own identity provider.

[![A tour of Restream: discover titles on Home, browse movies, play with captions, and preview subtitle styles in Settings.](docs/assets/showcase.gif)](docs/assets/showcase.png)

*Home, your library, and playback in one place. The preview uses a fictional demo library; select the image for a still view.*

## Features

- 📺 **Live TV and programme guide**: browse channels and see what's on now and next.
Expand All @@ -11,6 +15,7 @@ Restream brings your IPTV subscription to the browser. Host it on your own compu
- ⚡ **Shared library cache**: cached listings stay available while the server refreshes them in the background, reducing repeated requests to your provider.
- 🔐 **Optional OIDC sign-in**: use your identity provider with viewer and administrator roles, or run without a login on your private network.
- 📱 **Desktop and mobile**: responsive browsing, touch controls, and fullscreen playback.
- 💬 **Subtitles**: choose provider subtitles or embedded captions from the player's CC menu. Preview your preferred font, size, color, and background in Settings, saved for this browser. Availability varies by movie and episode.

## Get started

Expand Down
Binary file added docs/assets/showcase.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/showcase.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ go 1.27.2
require (
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de
github.com/alexedwards/scs/v2 v2.9.0
github.com/asticode/go-astisub v0.45.0
github.com/bluenviron/gohlslib/v2 v2.4.6
github.com/coreos/go-oidc/v3 v3.21.0
github.com/go-jose/go-jose/v4 v4.1.6
golang.org/x/oauth2 v0.37.0
Expand All @@ -13,12 +15,16 @@ require (
)

require (
github.com/asticode/go-astikit v0.30.0 // indirect
github.com/asticode/go-astits v1.16.0 // indirect
github.com/dustin/go-humanize v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/net v0.61.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
// SQLite requires the libc version in its own go.mod.
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
Expand Down
39 changes: 37 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,19 @@ github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de h1:c7
github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss=
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
github.com/asticode/go-astikit v0.30.0 h1:DkBkRQRIxYcknlaU7W7ksNfn4gMFsB0tqMJflxkRsZA=
github.com/asticode/go-astikit v0.30.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
github.com/asticode/go-astisub v0.45.0 h1:Y7bXw4Oa885ebDOfHUbj7rUSjyuYqSJYos1qEn0yUVw=
github.com/asticode/go-astisub v0.45.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ=
github.com/asticode/go-astits v1.16.0 h1:MgyLnG30oRiQsHWQNXA29ZS6i7UNN/OGcPdgH86JJqg=
github.com/asticode/go-astits v1.16.0/go.mod h1:psImoC1BhcAWIsyM/RnbO1OgUW+XSf8fZpFvF5McDyo=
github.com/bluenviron/gohlslib/v2 v2.4.6 h1:7ME4E0Lh2yAyN1VX9ixQDmn9UR3LKkkfv3FRqYfVy74=
github.com/bluenviron/gohlslib/v2 v2.4.6/go.mod h1:CEQYq/bNvzE/wjx92Jtl6ib8juZ+ZGKfJ4uldovzoOU=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4=
Expand All @@ -20,18 +31,42 @@ github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRU
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
Expand Down
1 change: 1 addition & 0 deletions internal/app/auth_playback_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ func TestOIDCPlaybackBelongsToItsUser(t *testing.T) {
{"DELETE", "/api/sessions/test-1", ""},
{"GET", "/api/streams/test-1/1/index.m3u8", ""},
{"GET", "/api/streams/test-1/1/seg-000001.ts", ""},
{"GET", "/api/streams/test-1/1/subtitles-0.json?start=30", ""},
}
for _, request := range paths {
resp := authPlaybackRequest(t, other, request.method, request.path, request.body)
Expand Down
153 changes: 153 additions & 0 deletions internal/app/embedded_captions_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package app

import (
"bytes"
"context"
"math/bits"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"

"github.com/pushpinderbal/restream/internal/model"
"github.com/pushpinderbal/restream/internal/stream"
)

func TestEmbeddedCaptionsAcrossPlaybackModes(t *testing.T) {
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg required")
}
movie := embeddedCaptionFixture(t)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, movie)
}))
defer upstream.Close()
for _, mode := range []string{"copy", "transcode"} {
t.Run(mode, func(t *testing.T) {
m, err := stream.New(stream.Config{MaxStreams: 1, SessionTTL: time.Minute, DataDir: t.TempDir(), TranscodeMode: mode}, func(context.Context, model.Item) (model.Source, error) {
return model.Source{URL: upstream.URL + "/captions.mp4"}, nil
})
if err != nil {
t.Fatal(err)
}
defer m.Close()
media := httptest.NewServer(m.MediaHandler())
defer media.Close()
for _, offset := range []float64{0, 43} {
s, err := m.Start(context.Background(), model.Item{Kind: "episode", ID: "episode:4:1:1"}, offset)
if err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(10 * time.Second)
for s.State == "starting" && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
s, err = m.Get(s.ID)
if err != nil {
t.Fatal(err)
}
}
if s.State != "ready" && s.State != "ended" {
t.Fatalf("caption relay did not start: %+v", s)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
input := strings.ReplaceAll(media.URL+s.URL, ":", "\\:")
cmd := exec.CommandContext(ctx, "ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "movie='"+input+"'[out0+subcc]", "-map", "0:s:0", "-t", "4", "-c:s", "webvtt", "-f", "webvtt", "-")
output, err := cmd.CombinedOutput()
cancel()
if err != nil || !bytes.Contains(output, []byte("Embedded caption fixture")) {
t.Fatalf("embedded captions lost in %s at %.0fs: %v %s", mode, offset, err, output)
}
if err := m.Stop(s.ID); err != nil {
t.Fatal(err)
}
}
})
}
}

// Generate a deterministic H.264 stream with real CEA-608 popup captions in
// registered A53 SEI messages. The fixture has no subtitle stream or sidecar.
func embeddedCaptionFixture(t *testing.T) string {
t.Helper()
dir := t.TempDir()
raw := filepath.Join(dir, "raw.h264")
cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=320x180:rate=25", "-t", "64", "-c:v", "libx264", "-preset", "ultrafast", "-g", "200", "-bf", "0", "-x264-params", "aud=1", "-f", "h264", raw)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("generate caption video: %v %s", err, output)
}
data, err := os.ReadFile(raw)
if err != nil {
t.Fatal(err)
}
startCode := []byte{0, 0, 0, 1}
var out bytes.Buffer
frame := -1
for _, nal := range bytes.Split(data, startCode) {
if len(nal) == 0 {
continue
}
out.Write(startCode)
if nal[0]&31 != 9 { // Access unit delimiter: one per frame.
out.Write(nal)
continue
}
frame++
if frame%25 != 0 {
out.Write(nal)
continue
}
out.Write(nal[:2])
pairs := [][2]byte{{0x14, 0x2c}} // Clear the displayed caption.
if frame%50 == 0 {
pairs = [][2]byte{{0x14, 0x20}, {0x14, 0x2e}, {0x11, 0x40}}
text := []byte("Embedded caption fixture")
for i := 0; i < len(text); i += 2 {
pairs = append(pairs, [2]byte{text[i], text[i+1]})
}
pairs = append(pairs, [2]byte{0x14, 0x2f}) // Display loaded popup.
}
payload := []byte{0xb5, 0, 0x31, 'G', 'A', '9', '4', 3, 0x40 | byte(len(pairs)), 0xff}
for _, pair := range pairs {
parity := func(b byte) byte {
if bits.OnesCount8(b)%2 == 0 {
return b | 0x80
}
return b
}
payload = append(payload, 0xfc, parity(pair[0]), parity(pair[1]))
}
payload = append(payload, 0xff)
sei := append([]byte{4, byte(len(payload))}, payload...)
sei = append(sei, 0x80)
out.Write(startCode)
out.WriteByte(6)
zeros := 0
for _, b := range sei {
if zeros == 2 && b <= 3 {
out.WriteByte(3)
zeros = 0
}
out.WriteByte(b)
if b == 0 {
zeros++
} else {
zeros = 0
}
}
out.Write(nal[2:])
}
captioned := filepath.Join(dir, "captions.h264")
if err := os.WriteFile(captioned, out.Bytes(), 0600); err != nil {
t.Fatal(err)
}
movie := filepath.Join(dir, "captions.mp4")
cmd = exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-r", "25", "-i", captioned, "-f", "lavfi", "-i", "sine=sample_rate=48000", "-t", "64", "-c:v", "copy", "-c:a", "aac", "-movflags", "+faststart", movie)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("mux caption fixture: %v %s", err, output)
}
return movie
}
68 changes: 54 additions & 14 deletions internal/app/playback_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,26 @@ func TestPortalToBrowserPlayback(t *testing.T) {
movie := filepath.Join(t.TempDir(), "sample.mp4")
// Leave enough footage after the browser's late seek to check that settings
// and library filtering preserve playback before the fixture reaches its end.
cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=320x180:rate=25", "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-t", "60", "-c:v", "mpeg4", "-q:v", "5", "-c:a", "aac", "-movflags", "+faststart", movie)
cmd := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=320x180:rate=25", "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-t", "60", "-c:v", "libx264", "-preset", "ultrafast", "-g", "200", "-sc_threshold", "0", "-bf", "2", "-c:a", "aac", "-movflags", "+faststart", movie)
if b, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("generate test media: %v: %s", err, b)
}
captionedMovie := embeddedCaptionFixture(t)
var mediaRequests atomic.Int32
var portal *httptest.Server
portal = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/media/subtitles.vtt" || r.URL.Path == "/media/spanish.srt" {
if !strings.Contains(r.Header.Get("Cookie"), "mac=00%3A11%3A22%3A33%3A44%3A55") {
t.Error("subtitle identity missing")
}
w.Header().Set("Content-Type", "text/vtt")
if strings.Contains(r.URL.Path, "spanish") {
io.WriteString(w, "1\n00:00:00,000 --> 00:01:00,000\nSubtítulos del proveedor\n")
return
}
io.WriteString(w, "WEBVTT\n\n00:00:00.000 --> 00:01:00.000\nProvider subtitle fixture\n")
return
}
if r.URL.Path == "/media/sample.mp4" {
if !strings.Contains(r.Header.Get("Cookie"), "mac=00%3A11%3A22%3A33%3A44%3A55") {
t.Error("server did not forward provider media identity")
Expand All @@ -45,6 +58,10 @@ func TestPortalToBrowserPlayback(t *testing.T) {
http.ServeFile(w, r, movie)
return
}
if r.URL.Path == "/media/captions.mp4" {
http.ServeFile(w, r, captionedMovie)
return
}
q := r.URL.Query()
var data any
switch q.Get("action") {
Expand All @@ -65,11 +82,30 @@ func TestPortalToBrowserPlayback(t *testing.T) {
data = []any{map[string]any{"id": "7", "title": "Test films"}}
}
case "get_ordered_list":
data = map[string]any{"total_items": 1, "data": []any{map[string]any{"id": "2", "name": "Test movie", "cmd": "private-vod-command"}}}
data = map[string]any{"total_items": 3, "data": []any{
map[string]any{"id": "2", "name": "Test movie", "cmd": "private-vod-command"},
map[string]any{"id": "4", "name": "Captioned episode", "cmd": "private-captioned-command"},
map[string]any{"id": "5", "name": "Captioned movie", "cmd": "private-captioned-sidecar-command"},
}}
case "get_epg_info":
data = map[string]any{"1": []any{map[string]any{"name": "Fixture guide", "start_timestamp": time.Now().Unix(), "stop_timestamp": time.Now().Add(time.Hour).Unix()}}}
case "create_link":
data = map[string]any{"cmd": "ffmpeg " + portal.URL + "/media/sample.mp4"}
if strings.Contains(q.Get("cmd"), "captioned") {
data = map[string]any{"cmd": "ffmpeg " + portal.URL + "/media/captions.mp4", "subtitles": []any{
map[string]any{"url": portal.URL + "/media/spanish.srt", "language": "es", "name": "Spanish"},
}}
if strings.Contains(q.Get("cmd"), "sidecar") {
data.(map[string]any)["subtitles"] = []any{
map[string]any{"url": portal.URL + "/media/subtitles.vtt", "language": "en", "name": "English"},
}
}
} else if !strings.Contains(q.Get("cmd"), "live") {
data.(map[string]any)["subtitles"] = []any{
map[string]any{"url": portal.URL + "/media/subtitles.vtt", "language": "en", "name": "English"},
map[string]any{"url": portal.URL + "/media/spanish.srt", "language": "es", "name": "Spanish"},
}
}
default:
t.Errorf("unexpected action %q", q.Get("action"))
http.Error(w, "unsupported", 400)
Expand All @@ -93,7 +129,7 @@ func TestPortalToBrowserPlayback(t *testing.T) {
if err != nil {
t.Fatal(err)
}
manager, err := stream.New(stream.Config{MaxStreams: 1, SessionTTL: time.Minute, DataDir: cfg.DataDir}, provider.Resolve)
manager, err := stream.New(stream.Config{MaxStreams: 1, SessionTTL: time.Minute, DataDir: cfg.DataDir, TranscodeMode: "transcode"}, provider.Resolve)
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -158,18 +194,22 @@ func TestPortalToBrowserPlayback(t *testing.T) {
t.Fatal("stopped media still accessible")
}
if os.Getenv("RESTREAM_BROWSER_TESTS") == "1" {
script, err := filepath.Abs("../../web/tests/real-playback.mjs")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "bun", script, server.URL)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("real browser playback failed: %v\n%s", err, output)
for _, check := range []struct{ script, mode string }{{"subtitles-playback.mjs", "copy"}, {"subtitles-playback.mjs", "transcode"}, {"embedded-captions-playback.mjs", "copy"}, {"embedded-captions-playback.mjs", "transcode"}, {"real-playback.mjs", "transcode"}} {
if err := manager.Configure(1, check.mode); err != nil {
t.Fatal(err)
}
script, err := filepath.Abs("../../web/tests/" + check.script)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
output, err := exec.CommandContext(ctx, "bun", script, server.URL, check.mode).CombinedOutput()
cancel()
if err != nil {
t.Fatalf("%s %s browser playback failed: %v\n%s", check.script, check.mode, err, output)
}
t.Log(string(output))
}
t.Log(string(output))
}

}
Expand Down
Loading
Loading