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
50 changes: 46 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,38 @@

IPTV streaming app for Samsung Tizen smart TVs and mobile PWA. Built with React, TypeScript, and Vite, with a Node.js backend server.

## Backend required

StreamVault is not a standalone TV player. The Tizen widget is a frontend and
requires the StreamVault backend to run on another device such as a PC, NAS,
Raspberry Pi, Proxmox guest, or server. Installing a `.wgt`, including through
Apps2Samsung, does not install that backend. The StreamVault backend URL is
separate from the Xtream server URL supplied by an IPTV provider.

Start the backend on a Docker host:

```bash
git clone https://github.com/christopherklint97/streamvault.git
cd streamvault
docker compose up -d --build
```

Verify it from another device on the same network, replacing `<backend-ip>`
with the Docker host's LAN address:

```bash
curl --fail http://<backend-ip>:3002/api/health
```

In the TV app, open **Settings**, enter `http://<backend-ip>:3002` under
**StreamVault Server URL**, and select **Connect**. Enter the provider's Xtream
server URL and credentials only after the backend connection succeeds. Backend
logs are available on the Docker host:

```bash
docker compose logs -f server
```

## Features

- **Live TV, Movies, Series** - Browse and play via Xtream Codes API or M3U playlists
Expand Down Expand Up @@ -57,14 +89,24 @@ npm run typecheck # TypeScript check for backend
npm run audit:prod # Production dependency audit
```

## Optional API hardening
Public widget builds intentionally leave the StreamVault backend URL unset so
each installation can configure its own server at runtime. For a private
preconfigured widget, set either a complete URL or a LAN IP explicitly:

Set `STREAMVAULT_AUTH_TOKEN` to protect config, sync/crawl, recordings, and recording-rule APIs. Browser clients include the token by storing it in localStorage:
```bash
VITE_SERVER_URL=http://192.168.1.20:3002 npm run build:tizen5
```

```js
localStorage.setItem('streamvault_auth_token', 'your-token')
```bash
VITE_SERVER_IP=192.168.1.20 npm run build:tizen5
```

## Optional API hardening

Set `STREAMVAULT_AUTH_TOKEN` to protect config, sync/crawl, recordings, and recording-rule APIs. Enter the same value in the optional **Backend token** field when connecting to the StreamVault backend. The client stores it locally only after the full connection check succeeds.

Tokens are backend-specific. StreamVault never sends the active backend's token while probing a different origin, and a successful switch replaces or clears the stored token.

The stream proxy validates URLs and blocks localhost/private/link-local targets. `/api/proxy` is limited to the configured Xtream server host plus optional `STREAMVAULT_PROXY_ALLOWED_HOSTS` entries.

Useful server environment variables:
Expand Down
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ services:
- streamvault_recordings:/app/data/recordings
environment:
- LOG_LEVEL=info
# Optional hardening. If set, browser clients must store the same value
# in localStorage key `streamvault_auth_token` for protected API calls.
# Optional hardening. If set, browser clients must enter the same value
# in Settings under Backend token.
- STREAMVAULT_AUTH_TOKEN=${STREAMVAULT_AUTH_TOKEN:-}
# Comma-separated extra hosts allowed for /api/proxy, in addition to the
# configured Xtream server host.
Expand Down
16 changes: 14 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useEffect, useCallback, useRef, useState } from 'react';
import { useEffect, useLayoutEffect, useCallback, useRef, useState } from 'react';
import { useAppStore } from './stores/appStore';
import { useChannelStore } from './stores/channelStore';
import { usePlayerStore } from './stores/playerStore';
import { resetBackendScopedStores } from './stores/backendReset';
import { useRemoteKeys } from './hooks/useRemoteKeys';
import { useNetworkStatus } from './hooks/useNetworkStatus';
import { KEY_CODES } from './utils/keys';
Expand Down Expand Up @@ -91,10 +92,20 @@ function AppContent() {
const loadingPhase = useChannelStore((s) => s.loadingPhase);
const cancelSync = useChannelStore((s) => s.cancelSync);
const hydrate = useChannelStore((s) => s.hydrate);
const backendGeneration = useChannelStore((s) => s.backendGeneration);
const apiBaseUrl = useChannelStore((s) => s.apiBaseUrl);
const previousBackendUrl = useRef(apiBaseUrl);
const { isOnline } = useNetworkStatus();

useRemoteKeys();

useLayoutEffect(() => {
if (backendGeneration > 0) {
resetBackendScopedStores(previousBackendUrl.current !== apiBaseUrl);
previousBackendUrl.current = apiBaseUrl;
}
}, [apiBaseUrl, backendGeneration]);

// Browser back button support (mobile PWA)
useEffect(() => {
// Replace initial state so we have a baseline
Expand Down Expand Up @@ -242,6 +253,7 @@ function AppContent() {
)}
{currentView !== 'player' && <Sidebar />}
<main
key={backendGeneration}
data-app-content
{...(currentView === 'player' ? { 'data-fullscreen': '' } : {})}
className={cn(
Expand All @@ -264,7 +276,7 @@ function AppContent() {
{/* Persistent video element — stays in the DOM across view changes so
playback continues in the background when navigating away from the player */}
{!(typeof webapis !== 'undefined' && webapis.avplay) && (
<AvPlayerVideo currentView={currentView} />
<AvPlayerVideo key={backendGeneration} currentView={currentView} />
)}
<Toast />
<ExitDialog />
Expand Down
16 changes: 15 additions & 1 deletion src/hooks/usePlayer.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { act, createRef, forwardRef, useImperativeHandle, type RefObject } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { seekRecordingPlayback, usePlayer } from './usePlayer';
import { seekRecordingPlayback, stopActivePlayback, usePlayer } from './usePlayer';
import { commercialSkipSession } from '../services/commercialSkipSession';
import { usePlayerStore } from '../stores/playerStore';
import { useAppStore } from '../stores/appStore';
Expand Down Expand Up @@ -53,6 +53,20 @@ describe('usePlayer manual seek integration', () => {
useAppStore.setState({ showToast: false, toastMessage: '' });
});

it('still closes AVPlay when stop throws during backend cleanup', () => {
const close = vi.fn();
(globalThis as typeof globalThis & { webapis: WebApis }).webapis = {
avplay: {
stop: vi.fn(() => { throw new Error('stop failed'); }),
close,
},
} as unknown as WebApis;

expect(() => stopActivePlayback()).not.toThrow();
expect(close).toHaveBeenCalledOnce();
expect(usePlayerStore.getState().status).toBe('idle');
});

it('invalidates a pending automatic skip as soon as scrubbing begins and before every manual seek', async () => {
const noteManualSeek = vi.spyOn(commercialSkipSession, 'noteManualSeek');

Expand Down
29 changes: 21 additions & 8 deletions src/hooks/usePlayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ function setupMediaSession(channelName: string) {
navigator.mediaSession.playbackState = 'paused';
});
navigator.mediaSession.setActionHandler('stop', () => {
stopPlayback();
stopActivePlayback();
});
navigator.mediaSession.setActionHandler('seekbackward', () => {
const v = getVideo();
Expand Down Expand Up @@ -311,7 +311,7 @@ function clearMediaSession() {
}

/** Fully stop playback — called from hook stop() and Media Session stop handler */
function stopPlayback() {
export function stopActivePlayback() {
log.info('⏹ stopPlayback()');

stopBgProgressTracking();
Expand All @@ -327,25 +327,38 @@ function stopPlayback() {

if (activeMpegtsPlayer) {
log.info('Destroying mpegts.js player');
activeMpegtsPlayer.destroy();
const player = activeMpegtsPlayer;
activeMpegtsPlayer = null;
try {
player.destroy();
} catch (err) {
toast(`Player cleanup error: ${err}`);
}
}

if (typeof webapis !== 'undefined' && webapis.avplay) {
clearAvplayStallTimer();
try {
webapis.avplay.stop();
} catch (err) {
toast(`Player cleanup error: ${err}`);
}
try {
webapis.avplay.close();
} catch (err) {
toast(`Player cleanup error: ${err}`);
}
} else {
const v = document.getElementById('av-player') as HTMLVideoElement | null;
if (v) {
v.pause();
v.removeAttribute('src');
delete v.dataset.channelId;
v.load();
try {
v.pause();
v.removeAttribute('src');
delete v.dataset.channelId;
v.load();
} catch (err) {
toast(`Player cleanup error: ${err}`);
}
}
}

Expand Down Expand Up @@ -951,7 +964,7 @@ export function usePlayer(): {
setSubtitleTracks([]);
setCurrentSubtitleIndex(-1);
setSubtitleText('');
stopPlayback();
stopActivePlayback();
}, []);

const retry = useCallback(() => {
Expand Down
82 changes: 82 additions & 0 deletions src/pages/Settings.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { useAppStore } from '../stores/appStore';
import { useChannelStore } from '../stores/channelStore';
import Settings from './Settings';

vi.mock('../stores/channelStore', async (importOriginal) => {
const actual = await importOriginal<typeof import('../stores/channelStore')>();
return { ...actual, SAME_ORIGIN: false };
});

(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

function autoSkipButton(container: HTMLElement): HTMLButtonElement {
Expand All @@ -17,16 +22,27 @@ describe('Settings commercial auto-skip integration', () => {
let container: HTMLDivElement;
let root: Root;
const saveConfig = vi.fn();
const connectBackend = vi.fn();
const triggerSync = vi.fn();
const showToastMessage = vi.fn();

beforeEach(async () => {
vi.clearAllMocks();
useChannelStore.setState({
commercialAutoSkip: false,
apiBaseUrl: 'https://dvr.example.test',
backendConnection: 'connected',
inputMode: 'xtream',
xtreamCredentials: {
serverUrl: 'https://provider.example.test',
username: 'subscriber',
password: 'secret',
},
isLoading: false,
error: null,
connectBackend,
saveConfig,
triggerSync,
});
useAppStore.setState({ showToastMessage });
container = document.createElement('div');
Expand Down Expand Up @@ -69,4 +85,70 @@ describe('Settings commercial auto-skip integration', () => {
expect(saveConfig).toHaveBeenCalledWith({ commercialAutoSkip: true });
expect(showToastMessage).toHaveBeenCalledWith('Automatic commercial skipping on');
});

it('hides provider controls whenever the backend has not been verified', async () => {
await act(async () => useChannelStore.setState({ backendConnection: 'disconnected' }));

expect(container.textContent).not.toContain('Xtream Codes Login');
expect(container.textContent).not.toContain('Connect & Sync');
});

it('passes an optional backend token when connecting to a protected server', async () => {
connectBackend.mockResolvedValue(true);
await act(async () => useChannelStore.setState({ backendConnection: 'disconnected' }));
const serverInput = container.querySelector<HTMLInputElement>('input[placeholder="http://backend-ip:3002"]');
const tokenInput = container.querySelector<HTMLInputElement>('input[placeholder="Optional backend token"]');
if (!serverInput || !tokenInput) throw new Error('Missing backend connection fields');

await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
valueSetter?.call(serverInput, 'https://protected.example.test');
serverInput.dispatchEvent(new Event('input', { bubbles: true }));
valueSetter?.call(tokenInput, 'new-backend-token');
tokenInput.dispatchEvent(new Event('input', { bubbles: true }));
});
const connectButton = Array.from(container.querySelectorAll('button'))
.find((candidate) => candidate.textContent === 'Connect');
if (!connectButton) throw new Error('Missing Connect button');

await act(async () => connectButton.click());

expect(connectBackend).toHaveBeenCalledWith('https://protected.example.test', 'new-backend-token');
});

it('does not start a sync when saving Xtream credentials fails', async () => {
saveConfig.mockResolvedValue(false);
const button = Array.from(container.querySelectorAll('button'))
.find((candidate) => candidate.textContent?.includes('Connect & Sync'));
if (!button) throw new Error('Missing Connect & Sync button');

await act(async () => button.click());

expect(saveConfig).toHaveBeenCalledWith({
inputMode: 'xtream',
xtreamServer: 'https://provider.example.test',
xtreamUsername: 'subscriber',
xtreamPassword: 'secret',
});
expect(triggerSync).not.toHaveBeenCalled();
});

it('reinitializes provider drafts after connecting a different backend', async () => {
await act(async () => useChannelStore.setState({
apiBaseUrl: 'https://second-backend.example.test',
backendConnection: 'connected',
xtreamCredentials: {
serverUrl: 'https://second-provider.example.test',
username: 'second-user',
password: 'second-password',
},
}));

expect(container.querySelector<HTMLInputElement>('input[placeholder="http://example.com"]')?.value)
.toBe('https://second-provider.example.test');
expect(container.querySelector<HTMLInputElement>('input[placeholder="Your username"]')?.value)
.toBe('second-user');
expect(container.querySelector<HTMLInputElement>('input[placeholder="Your password"]')?.value)
.toBe('second-password');
});
});
56 changes: 56 additions & 0 deletions src/pages/Settings.same-origin.integration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useChannelStore } from '../stores/channelStore';
import Settings from './Settings';

vi.mock('../stores/channelStore', async (importOriginal) => {
const actual = await importOriginal<typeof import('../stores/channelStore')>();
return { ...actual, SAME_ORIGIN: true };
});

(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

describe('same-origin backend authentication', () => {
let container: HTMLDivElement;
let root: Root;
const connectBackend = vi.fn();

beforeEach(async () => {
vi.clearAllMocks();
connectBackend.mockResolvedValue(true);
useChannelStore.setState({
apiBaseUrl: '',
backendConnection: 'disconnected',
error: null,
isLoading: false,
connectBackend,
});
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => root.render(<Settings />));
});

afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});

it('allows a protected same-origin backend token to be entered before connecting', async () => {
const tokenInput = container.querySelector<HTMLInputElement>('input[placeholder="Optional backend token"]');
if (!tokenInput) throw new Error('Missing backend token field');
await act(async () => {
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
valueSetter?.call(tokenInput, 'same-origin-token');
tokenInput.dispatchEvent(new Event('input', { bubbles: true }));
});
const connectButton = Array.from(container.querySelectorAll('button'))
.find((candidate) => candidate.textContent === 'Connect');
if (!connectButton) throw new Error('Missing Connect button');

await act(async () => connectButton.click());

expect(connectBackend).toHaveBeenCalledWith('', 'same-origin-token');
});
});
Loading
Loading