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
49 changes: 34 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,38 @@ env:
IMAGE_NAME: ${{ github.repository }}

jobs:
# test:
# name: Test
# if: github.event_name == 'pull_request'
# runs-on: ubuntu-latest
#
# steps:
# - name: Check out repository
# uses: actions/checkout@v7
#
# - name: Install tools with mise
# uses: jdx/mise-action@v5
#
# - name: Run mise test tasks
# shell: bash
# run: mise run test
test:
name: Test and build
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out repository
uses: actions/checkout@v7
- name: Install tools with mise
uses: jdx/mise-action@v5
- name: Install playback test dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ffmpeg
- name: Cache Go dependencies and builds
uses: actions/cache@v5
with:
path: |
~/go/pkg/mod
~/.cache/go-build
key: ${{ runner.os }}-go-${{ hashFiles('go.mod', 'go.sum') }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-go-${{ hashFiles('go.mod', 'go.sum') }}-
- name: Cache frontend packages
uses: actions/cache@v5
with:
path: ~/.bun/install/cache
key: ${{ runner.os }}-bun-${{ hashFiles('web/bun.lock') }}
- name: Run integration tests and build frontend
run: |
mise run test ::: frontend

publish:
name: Build and publish image
Expand Down Expand Up @@ -74,6 +91,8 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=restream
cache-to: type=gha,mode=max,scope=restream

- name: Generate artifact attestation
uses: actions/attest-build-provenance@v4
Expand Down
16 changes: 11 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
FROM oven/bun:1.4.0 AS frontend
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM oven/bun:1.4.0 AS frontend
WORKDIR /build/web
COPY web/package.json web/bun.lock ./
RUN bun install --frozen-lockfile
COPY web/ ./
RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile
COPY web/index.html web/tsconfig*.json web/vite.config.ts ./
COPY web/src/ ./src/
COPY web/public/ ./public/
RUN bun run build

FROM golang:1.27-alpine3.24 AS backend
FROM --platform=$BUILDPLATFORM golang:1.27-alpine3.24 AS backend
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY cmd/ ./cmd/
COPY internal/ ./internal/
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /restream ./cmd/restream
ARG TARGETOS
ARG TARGETARCH
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /restream ./cmd/restream

FROM alpine:3.24 AS runtime
LABEL org.opencontainers.image.title="Restream" \
Expand Down
129 changes: 73 additions & 56 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,75 +1,92 @@
# Restream

Restream is a browser player for one Stalker/MAG IPTV portal. Watch live TV, movies, and series from devices on your network without installing an IPTV app on each one.
Restream is a web proxy for your Stalker/MAG IPTV provider. Multiple users can watch live TV, movies, and series from the same provider account in a web browser.

```text
Browsers <-- HTTP(S) --> Restream <-- Stalker portal --> Provider
|
+-- SQLite cache in /data
+-- FFmpeg --> HLS playback
```

## Run with Docker Compose

```sh
git clone https://github.com/pushpinderbal/restream.git
cd restream
cp .env.example .env
```

Set `STALKER_PORTAL_URL` and `STALKER_MAC` in `.env` to your portal URL and registered MAC address, then run:
Each active player uses an upstream stream. Simultaneous viewing is limited by your provider's stream allocation and Restream's `MAX_STREAMS` setting. Paused players still count toward that limit.

```sh
docker compose up -d
```
You need your provider's portal URL and registered MAC address, plus any serial number or device IDs your provider requires.

Compose pulls `ghcr.io/pushpinderbal/restream:latest`. Open `http://localhost:8080` or your server's LAN address. Keep `.env` private. See [.env.example](.env.example) for optional portal device identity and refresh settings.
## Run with Docker

Choose Live TV, Movies, or Series, browse or search, then open a title and press **Play**.

Open **Settings** to check library and programme guide sync states, last successful and upcoming refreshes, guide coverage, and stream usage. Use the refresh icon beside each sync state to start a background refresh without interrupting playback. The icon spins and its state changes to **Syncing** while the refresh runs. A successful library refresh updates live channels and categories and expires cached movie, series, and episode lists. Open lists reload automatically; other lists fetch fresh data when next visited. Refreshes respect provider cooldowns. Intervals and playback limits are configured in `.env`.

To update the published app:
Replace the example portal URL and MAC address with your provider's details:

```sh
docker compose pull
docker compose up -d
docker run -d \
--name restream \
--restart unless-stopped \
--stop-timeout 20 \
-p 8080:8080 \
-v restream-data:/data \
-e STALKER_PORTAL_URL='http://your-provider.example' \
-e STALKER_MAC='00:11:22:33:44:55' \
-e MAX_STREAMS=1 \
ghcr.io/pushpinderbal/restream:latest
```

## Develop with Docker Compose

Use the standalone `compose.dev.yaml` for local development. Configure `.env` as above, then run from the repository root:

```sh
docker compose -f compose.dev.yaml up --build --watch
## Or use Docker Compose

Save this as `compose.yaml` and enter your provider's details in `environment`:

```yaml
services:
restream:
image: ghcr.io/pushpinderbal/restream:latest
ports:
- "8080:8080"
environment:
STALKER_PORTAL_URL: "http://your-provider.example"
STALKER_MAC: "00:11:22:33:44:55"
STALKER_TIMEZONE: "UTC"
MAX_STREAMS: "1"
volumes:
- restream-data:/data
restart: unless-stopped
stop_grace_period: 20s

volumes:
restream-data:
```

Or use `mise run dev` if you have [mise](https://mise.jdx.dev/) installed. Development only requires Docker Compose 2.32 or newer; Go, Bun, and FFmpeg run inside the containers.

Open `http://localhost:5173` or your development machine's LAN address on port 5173. The frontend proxies API requests and video playback to the development backend over the Compose network.

- React and CSS edits hot reload from the mounted `web/` directory, without a container rebuild.
- Go source changes automatically rebuild and restart the backend through Compose Watch. Active playback stops when the backend restarts.
- Changes to `web/package.json` or `web/bun.lock` restart the frontend and reinstall dependencies.
- Development uses separate data and dependency volumes from the published app. The backend build skips the production UI bundle.

Stop development with Ctrl+C, or remove its containers while retaining cached data:
Start it with:

```sh
docker compose -f compose.dev.yaml down
docker compose up -d
```

The equivalent mise commands are `mise run dev:logs` and `mise run dev:down`. Plain `docker compose up -d` continues to run the published app on port 8080. Use the development file by itself, rather than combining it with `compose.yaml`.

To work on the UI using a locally installed Bun instead, keep a backend running on `localhost:8080` and run `mise run dev:ui`, or run `bun install --frozen-lockfile` followed by `bun run dev` from `web/`.

## How it works

Restream caches live channels, guide data, categories, and requested movie and series pages in SQLite under `/data`. It fetches movie and series pages as people browse; it does not download the full catalog at startup. Movie and series pages expire after `CATALOG_REFRESH_INTERVAL` (default `24h`). Episode lists are shared after the first visit and expire independently after `EPISODE_CACHE_TTL` (default `1h`); reopening a series after expiry checks for new episodes. Successful manual and scheduled library refreshes expire these lists immediately while retaining title records used by active playback. Keep the `/data` volume to retain the cache across restarts.

Each viewer uses a separate upstream stream. `MAX_STREAMS` (default: `1`) limits simultaneous players, including paused players; when all slots are in use, the browser shows a warning. Set the limit within your provider's allowance.

Restream has no built-in client authentication. If you expose it beyond your LAN, put authentication in front of it and proxy the entire site, including `/api/streams/`. Run one container replica because playback slots and FFmpeg sessions are local to that container.
Open `http://localhost:8080`, or `http://<server-address>:8080` from another device. Choose **Live TV**, **Movies**, or **Series**, open a title, and press **Play**. Use **Settings** to check or refresh the library and programme guide. Stop playback to release a stream for another viewer.

Restream has no sign-in screen. Keep access private or protect it with authentication before making it available outside your network.

## Environment variables

Pass these with Docker's `-e` option or add them to the Compose `environment` section. Defaults below are for the Docker image and apply when a setting is omitted or empty. Duration values use units such as `250ms`, `45s`, `1m`, and `24h`.

`-` means no default is supplied.

| Variable | Default | Description |
| --- | --- | --- |
| `STALKER_PORTAL_URL` | - | (required) Your provider's portal URL, such as `http://your-provider.example`. |
| `STALKER_MAC` | - | (required) The MAC address registered with your provider. |
| `STALKER_TIMEZONE` | `UTC` | Timezone used with the provider, such as `America/Toronto`. |
| `STALKER_SERIAL_NUMBER` | - | Registered device serial number. Required only if your provider asks for it. |
| `STALKER_DEVICE_ID` | - | Registered device ID. Required only if your provider asks for it. |
| `STALKER_DEVICE_ID2` | - | Second registered device ID. Required only if your provider asks for it. |
| `STALKER_USER_AGENT` | Built-in MAG user agent | Override the device identification only if your provider requires a specific value. |
| `MAX_STREAMS` | `1` | Maximum simultaneous players. Increase only within your provider's allowance. Must be at least 1. |
| `CATALOG_REFRESH_INTERVAL` | `24h` | How often to refresh the library and how long to keep saved movie/series listings. Minimum `1m`. |
| `EPG_REFRESH_INTERVAL` | `6h` | How often to refresh the programme guide. Minimum `1m`. |
| `EPISODE_CACHE_TTL` | `1h` | How long to keep an episode list before checking for updates when it is opened again. Minimum `1m`. |
| `STALKER_EPG_HOURS` | `6` | Hours of programme guide information to request. Range: 1–168. |
| `STALKER_REQUEST_TIMEOUT` | `1m` | Maximum wait for a provider request. Minimum `1s`. |
| `STALKER_REQUEST_INTERVAL` | `250ms` | Minimum delay between provider requests. Minimum `100ms`. |
| `STALKER_MAX_RESPONSE_MB` | `64` | Maximum size of a provider response in MB. Range: 1–512. |
| `SESSION_TTL` | `45s` | Release a stream after its browser stops checking in. Minimum `30s`. |
| `TRANSCODE_MODE` | `auto` | `auto`: convert video when needed; `copy`: pass it through without conversion; `transcode`: always convert it. |
| `LISTEN_ADDR` | `:8080` | Address and port inside the container. Match the container port in your Docker port mapping if changed. |
| `DATA_DIR` | `/data` | Storage for saved library information and temporary playback files. Match your volume mount if changed. |
| `WEB_DIR` | `/app/web` | Location of the browser interface files. Change only if you provide those files at a different location. |

FFmpeg and FFprobe are included in the Docker image and used automatically.

## License

Expand Down
2 changes: 1 addition & 1 deletion cmd/restream/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func run() error {
}
var manager *stream.Manager
if provider != nil {
manager, err = stream.New(stream.Config{MaxStreams: cfg.MaxStreams, SessionTTL: cfg.SessionTTL, DataDir: cfg.DataDir, FFmpegPath: cfg.FFmpegPath, FFprobePath: cfg.FFprobePath, TranscodeMode: cfg.TranscodeMode}, provider.Resolve)
manager, err = stream.New(stream.Config{MaxStreams: cfg.MaxStreams, SessionTTL: cfg.SessionTTL, DataDir: cfg.DataDir, TranscodeMode: cfg.TranscodeMode}, provider.Resolve)
if err != nil {
return err
}
Expand Down
4 changes: 2 additions & 2 deletions internal/app/artwork_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,11 @@ func TestBrowseCacheUpgradePreservesOtherSavedData(t *testing.T) {
t.Fatal(err)
}
q := model.BrowseQuery{Kind: "movie", Category: "7", Search: "literal", Page: 1}
if err := s.setBrowse(q, model.BrowsePage{Items: []model.Item{{ID: "movie:3", Kind: "movie", Name: "Cached film"}}, Page: 1}, time.Hour); err != nil {
if err := s.setBrowseAtRevision(q, model.BrowsePage{Items: []model.Item{{ID: "movie:3", Kind: "movie", Name: "Cached film"}}, Page: 1}, time.Hour, s.libraryRevision()); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(2 * time.Minute)
if err := s.setRetryDeadline(false, deadline, true); err != nil {
if err := s.setPortalCooldown(deadline); err != nil {
t.Fatal(err)
}
if _, err := s.db.Exec(`DELETE FROM meta WHERE key='browse_cache_version'`); err != nil {
Expand Down
4 changes: 2 additions & 2 deletions internal/app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ type Config struct {
MaxStreams int
SessionTTL, CatalogRefresh, EPGRefresh, RequestInterval time.Duration
EpisodeCacheTTL time.Duration
FFmpegPath, FFprobePath, TranscodeMode string
TranscodeMode string
}

func LoadConfig() (Config, error) {
c := Config{
ListenAddr: env("LISTEN_ADDR", ":8080"), DataDir: env("DATA_DIR", "./data"), WebDir: env("WEB_DIR", "./web/dist"),
PortalURL: strings.TrimSpace(os.Getenv("STALKER_PORTAL_URL")), MAC: strings.TrimSpace(os.Getenv("STALKER_MAC")),
Timezone: env("STALKER_TIMEZONE", "UTC"), UserAgent: os.Getenv("STALKER_USER_AGENT"), SerialNumber: os.Getenv("STALKER_SERIAL_NUMBER"), DeviceID: os.Getenv("STALKER_DEVICE_ID"), DeviceID2: os.Getenv("STALKER_DEVICE_ID2"),
FFmpegPath: env("FFMPEG_PATH", "ffmpeg"), FFprobePath: env("FFPROBE_PATH", "ffprobe"), TranscodeMode: env("TRANSCODE_MODE", "auto"),
TranscodeMode: env("TRANSCODE_MODE", "auto"),
}
var err error
responseMB, err := strconv.ParseInt(env("STALKER_MAX_RESPONSE_MB", "64"), 10, 32)
Expand Down
6 changes: 3 additions & 3 deletions internal/app/episodes_failure_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func TestEpisodeFailureLoggedOnceWithoutPrivateDetails(t *testing.T) {
}
defer s.Close()
q := model.BrowseQuery{Kind: "series", Category: "*", Page: 1}
if err := s.cache.setBrowse(q, model.BrowsePage{Items: []model.Item{{ID: "series:229631", Kind: "series", Name: "Cached series", ProviderID: "229631"}}, Page: 1}, time.Hour); err != nil {
if err := s.cache.setBrowseAtRevision(q, model.BrowsePage{Items: []model.Item{{ID: "series:229631", Kind: "series", Name: "Cached series", ProviderID: "229631"}}, Page: 1}, time.Hour, s.cache.libraryRevision()); err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(s)
Expand Down Expand Up @@ -94,11 +94,11 @@ func TestCachedEpisodePlaybackRetainsProviderIdentityAfterRestart(t *testing.T)
t.Fatal(err)
}
series := model.Item{ID: "series:229631", Kind: "series", Name: "Show", ProviderID: "229631"}
if err := cache.setBrowse(model.BrowseQuery{Kind: "series", Category: "*", Page: 1}, model.BrowsePage{Page: 1, Items: []model.Item{series}}, time.Hour); err != nil {
if err := cache.setBrowseAtRevision(model.BrowseQuery{Kind: "series", Category: "*", Page: 1}, model.BrowsePage{Page: 1, Items: []model.Item{series}}, time.Hour, cache.libraryRevision()); err != nil {
t.Fatal(err)
}
episode := model.Item{ID: "episode:229631:16659:730024", Kind: "episode", Name: "Pilot", Season: 1, Episode: 1, ProviderID: "730024", SeriesID: "229631", EpisodeID: "730024"}
if err := cache.setEpisodes(series.ID, []model.Item{episode}); err != nil {
if err := cache.setEpisodesAtRevision(series.ID, []model.Item{episode}, cache.libraryRevision()); err != nil {
t.Fatal(err)
}
if err := cache.close(); err != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/app/library_refresh_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ func TestEpisodeCacheExpiresIndependentlyOfSeriesPages(t *testing.T) {

func TestRefreshDoesNotJoinOrCacheOlderListRequests(t *testing.T) {
s, ts, p := newChangingLibrary(t, time.Hour)
if err := s.cache.setBrowse(model.BrowseQuery{Kind: "series", Category: "seed", Page: 1}, model.BrowsePage{Items: []model.Item{{ID: "series:2", Kind: "series"}}}, 24*time.Hour); err != nil {
if err := s.cache.setBrowseAtRevision(model.BrowseQuery{Kind: "series", Category: "seed", Page: 1}, model.BrowsePage{Items: []model.Item{{ID: "series:2", Kind: "series"}}}, 24*time.Hour, s.cache.libraryRevision()); err != nil {
t.Fatal(err)
}
gate := make(chan struct{})
Expand Down
2 changes: 1 addition & 1 deletion internal/app/refresh_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ func TestSettingsManualRefreshLifecycle(t *testing.T) {
t.Fatal("cross-origin refresh accepted", res.StatusCode)
}
cooldown := time.Now().Add(time.Hour)
if err := s.cache.setRetryDeadline(false, cooldown, true); err != nil {
if err := s.cache.setPortalCooldown(cooldown); err != nil {
t.Fatal(err)
}
req, _ = http.NewRequest("POST", ts.URL+"/api/refresh", strings.NewReader(`{"target":"all"}`))
Expand Down
12 changes: 8 additions & 4 deletions internal/app/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ func New(cfg Config, provider model.Provider, streams Streams) (*Server, error)
}
imageCache, err := ristretto.NewCache(&ristretto.Config[string, imageData]{NumCounters: 1024, MaxCost: 32 << 20, BufferItems: 64})
if err != nil {
_ = cache.close()
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
Expand All @@ -111,7 +112,7 @@ func New(cfg Config, provider model.Provider, streams Streams) (*Server, error)
until := cache.portalCooldownUntil
cache.mu.RUnlock()
cooldownProvider.ConfigureCooldown(until, func(next time.Time) error {
if err := cache.setRetryDeadline(false, next, true); err != nil {
if err := cache.setPortalCooldown(next); err != nil {
return err
}
slog.Debug("Portal cooldown saved", "until", next, "wait", max(0, time.Until(next)).Round(time.Second))
Expand Down Expand Up @@ -548,7 +549,7 @@ func (s *Server) scheduler(catalog bool) {
next = time.Now().Add(retryCooldown(err, failures))
var limited interface{ RetryDelay() time.Duration }
if errors.As(err, &limited) && limited.RetryDelay() > 0 {
_ = s.cache.setRetryDeadline(false, next, true)
_ = s.cache.setPortalCooldown(next)
}
slog.Info("Refresh retry scheduled", "kind", kind, "nextAt", next, "failures", failures)
} else {
Expand Down Expand Up @@ -924,8 +925,11 @@ func (s *Server) fetchImage(rawURL string) (imageData, error) {
return imageData{}, fmt.Errorf("image upstream returned %d", res.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(res.Body, (5<<20)+1))
if err != nil || len(data) > 5<<20 {
return imageData{}, fmt.Errorf("image too large or unreadable: %w", err)
if err != nil {
return imageData{}, fmt.Errorf("image unreadable: %w", err)
}
if len(data) > 5<<20 {
return imageData{}, errors.New("image too large")
}
mime := http.DetectContentType(data)
switch mime {
Expand Down
Loading
Loading