diff --git a/README.md b/README.md index 7a6f950..795113b 100644 --- a/README.md +++ b/README.md @@ -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 `` +with the Docker host's LAN address: + +```bash +curl --fail http://:3002/api/health +``` + +In the TV app, open **Settings**, enter `http://: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 @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index 2a609e4..ac62ae9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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. diff --git a/src/App.tsx b/src/App.tsx index 52d056c..f64b0a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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'; @@ -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 @@ -242,6 +253,7 @@ function AppContent() { )} {currentView !== 'player' && }
+ )} diff --git a/src/hooks/usePlayer.integration.test.tsx b/src/hooks/usePlayer.integration.test.tsx index a76871a..a6ac073 100644 --- a/src/hooks/usePlayer.integration.test.tsx +++ b/src/hooks/usePlayer.integration.test.tsx @@ -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'; @@ -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'); diff --git a/src/hooks/usePlayer.ts b/src/hooks/usePlayer.ts index 86a61e5..f6cf920 100644 --- a/src/hooks/usePlayer.ts +++ b/src/hooks/usePlayer.ts @@ -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(); @@ -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(); @@ -327,14 +327,23 @@ 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}`); @@ -342,10 +351,14 @@ function stopPlayback() { } 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}`); + } } } @@ -951,7 +964,7 @@ export function usePlayer(): { setSubtitleTracks([]); setCurrentSubtitleIndex(-1); setSubtitleText(''); - stopPlayback(); + stopActivePlayback(); }, []); const retry = useCallback(() => { diff --git a/src/pages/Settings.integration.test.tsx b/src/pages/Settings.integration.test.tsx index 69893a3..eb5eced 100644 --- a/src/pages/Settings.integration.test.tsx +++ b/src/pages/Settings.integration.test.tsx @@ -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(); + return { ...actual, SAME_ORIGIN: false }; +}); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; function autoSkipButton(container: HTMLElement): HTMLButtonElement { @@ -17,6 +22,8 @@ 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 () => { @@ -24,9 +31,18 @@ describe('Settings commercial auto-skip integration', () => { 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'); @@ -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('input[placeholder="http://backend-ip:3002"]'); + const tokenInput = container.querySelector('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('input[placeholder="http://example.com"]')?.value) + .toBe('https://second-provider.example.test'); + expect(container.querySelector('input[placeholder="Your username"]')?.value) + .toBe('second-user'); + expect(container.querySelector('input[placeholder="Your password"]')?.value) + .toBe('second-password'); + }); }); diff --git a/src/pages/Settings.same-origin.integration.test.tsx b/src/pages/Settings.same-origin.integration.test.tsx new file mode 100644 index 0000000..fbc2f5a --- /dev/null +++ b/src/pages/Settings.same-origin.integration.test.tsx @@ -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(); + 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()); + }); + + 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('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'); + }); +}); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 92911d0..22e7916 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -33,6 +33,12 @@ function isValidUrl(url: string): boolean { } export default function Settings() { + const apiBaseUrl = useChannelStore((s) => s.apiBaseUrl); + const backendConnection = useChannelStore((s) => s.backendConnection); + return ; +} + +function SettingsContent() { const inputMode = useChannelStore((s) => s.inputMode); const xtreamCredentials = useChannelStore((s) => s.xtreamCredentials); const playlistUrl = useChannelStore((s) => s.playlistUrl); @@ -49,18 +55,19 @@ export default function Settings() { const crawlProgress = useChannelStore((s) => s.crawlProgress); const lastCrawlTime = useChannelStore((s) => s.lastCrawlTime); const apiBaseUrl = useChannelStore((s) => s.apiBaseUrl); + const backendConnection = useChannelStore((s) => s.backendConnection); const commercialAutoSkip = useChannelStore((s) => s.commercialAutoSkip); - const setApiBaseUrl = useChannelStore((s) => s.setApiBaseUrl); + const connectBackend = useChannelStore((s) => s.connectBackend); const saveConfig = useChannelStore((s) => s.saveConfig); const triggerSync = useChannelStore((s) => s.triggerSync); const cancelSync = useChannelStore((s) => s.cancelSync); const triggerCrawl = useChannelStore((s) => s.triggerCrawl); const cancelCrawl = useChannelStore((s) => s.cancelCrawl); - const hydrate = useChannelStore((s) => s.hydrate); const showToastMessage = useAppStore((s) => s.showToastMessage); const [subtitlesOn, setSubtitlesOn] = useState(getSubtitlesEnabled()); const [localApiUrl, setLocalApiUrl] = useState(apiBaseUrl); + const [localApiToken, setLocalApiToken] = useState(''); const [localServerUrl, setLocalServerUrl] = useState(xtreamCredentials.serverUrl); const [localUsername, setLocalUsername] = useState(xtreamCredentials.username); const [localPassword, setLocalPassword] = useState(xtreamCredentials.password); @@ -70,22 +77,17 @@ export default function Settings() { const [commercialAutoSkipSaving, setCommercialAutoSkipSaving] = useState(false); const handleConnectServer = useCallback(async () => { - if (!localApiUrl.trim()) { setFieldError('Please enter a server URL'); return; } - if (!isValidUrl(localApiUrl)) { setFieldError('Server URL must start with http:// or https://'); return; } + if (!SAME_ORIGIN && !localApiUrl.trim()) { setFieldError('Please enter a server URL'); return; } + if (!SAME_ORIGIN && !isValidUrl(localApiUrl)) { setFieldError('Server URL must start with http:// or https://'); return; } setFieldError(''); - const url = localApiUrl.trim().replace(/\/+$/, ''); - setApiBaseUrl(url); - // Test connection + load data - try { - const response = await fetch(`${url}/api/status`); - if (!response.ok) throw new Error(`HTTP ${response.status}`); - showToastMessage('Connected to server'); - // Trigger full hydration - setTimeout(() => hydrate(), 100); - } catch { - setFieldError('Cannot connect to server'); + const url = SAME_ORIGIN ? '' : localApiUrl.trim().replace(/\/+$/, ''); + const connected = await connectBackend(url, localApiToken); + if (!connected) { + setFieldError(useChannelStore.getState().error || 'Cannot connect to the StreamVault backend'); + return; } - }, [localApiUrl, setApiBaseUrl, showToastMessage, hydrate]); + showToastMessage('Connected to StreamVault backend'); + }, [localApiUrl, localApiToken, connectBackend, showToastMessage]); const handleModeSwitch = useCallback(async (mode: InputMode) => { setFieldError(''); @@ -98,12 +100,13 @@ export default function Settings() { if (!localUsername.trim()) { setFieldError('Please enter a username'); return; } if (!localPassword.trim()) { setFieldError('Please enter a password'); return; } setFieldError(''); - await saveConfig({ + const saved = await saveConfig({ inputMode: 'xtream', xtreamServer: localServerUrl.trim(), xtreamUsername: localUsername.trim(), xtreamPassword: localPassword.trim(), }); + if (!saved) return; await triggerSync(); }, [localServerUrl, localUsername, localPassword, saveConfig, triggerSync]); @@ -111,11 +114,12 @@ export default function Settings() { if (!localPlaylistUrl.trim()) { setFieldError('Please enter a playlist URL'); return; } if (!isValidUrl(localPlaylistUrl)) { setFieldError('URL must start with http:// or https://'); return; } setFieldError(''); - await saveConfig({ + const saved = await saveConfig({ inputMode: 'manual', playlistUrl: localPlaylistUrl.trim(), epgUrl: localEpgUrl.trim(), }); + if (!saved) return; await triggerSync(); }, [localPlaylistUrl, localEpgUrl, saveConfig, triggerSync]); @@ -169,12 +173,12 @@ export default function Settings() { const currentIndex = SYNC_OPTIONS.findIndex((o) => o.value === syncInterval); const nextIndex = (currentIndex + 1) % SYNC_OPTIONS.length; const next = SYNC_OPTIONS[nextIndex]; - await saveConfig({ syncInterval: next.value }); - showToastMessage(`Sync: ${next.label}`); + const saved = await saveConfig({ syncInterval: next.value }); + if (saved) showToastMessage(`Sync: ${next.label}`); }, [syncInterval, saveConfig, showToastMessage]); const syncLabel = SYNC_OPTIONS.find((o) => o.value === syncInterval)?.label ?? 'Every 24 hours'; - const isConnected = SAME_ORIGIN || !!apiBaseUrl; + const isConnected = backendConnection === 'connected'; return ( @@ -222,10 +226,10 @@ export default function Settings() {
{loadingMessage}
)} - {/* Server Connection — hidden when PWA is served from same origin */} - {!SAME_ORIGIN && ( -
-

Server

+ {/* Server URL is configurable on local widgets; hosted PWAs only need the optional token. */} +
+

Server

+ {!SAME_ORIGIN && (
setLocalApiUrl(e.target.value)} - placeholder="http://192.168.0.100:3001" + placeholder="http://backend-ip:3002" />
- {fieldError && !isConnected && {fieldError}} - + )} +
+ + setLocalApiToken(e.target.value)} + placeholder="Optional backend token" + autoComplete="off" + /> + Only needed when the backend uses STREAMVAULT_AUTH_TOKEN.
- )} + {fieldError && !isConnected && {fieldError}} + +
{isConnected && ( <> diff --git a/src/services/api.test.ts b/src/services/api.test.ts index 26eddf6..e51d650 100644 --- a/src/services/api.test.ts +++ b/src/services/api.test.ts @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { apiFetch } from './api'; +import { apiFetch, probeBackend, rotateBackendRequestScope, StaleBackendRequestError } from './api'; describe('apiFetch', () => { beforeEach(() => { localStorage.clear(); vi.restoreAllMocks(); + rotateBackendRequestScope(); }); it('adds the stored API token as a header without putting it in the URL', async () => { @@ -45,4 +46,45 @@ describe('apiFetch', () => { })).resolves.toBeUndefined(); expect(new Headers(fetchMock.mock.calls[0][1]?.headers).get('Content-Type')).toBe('application/json'); }); + + it('turns an opaque browser network failure into an actionable backend error', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to fetch')); + + await expect(apiFetch('http://192.168.1.20:3002', '/api/status')) + .rejects.toThrow( + 'Cannot reach the StreamVault backend. Check the StreamVault Server URL and make sure the backend is running.', + ); + }); + + it('probes an untrusted backend candidate without disclosing the stored API token', async () => { + localStorage.setItem('streamvault_auth_token', JSON.stringify('old-backend-secret')); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + + await probeBackend('http://candidate.example.test:3002'); + + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe('http://candidate.example.test:3002/api/status'); + expect(new Headers(init?.headers).has('x-streamvault-token')).toBe(false); + expect(new Headers(init?.headers).has('authorization')).toBe(false); + }); + + it('rejects an old backend response after the request scope rotates', async () => { + let resolveOld!: (response: Response) => void; + vi.spyOn(globalThis, 'fetch').mockReturnValue(new Promise((resolve) => { + resolveOld = resolve; + })); + + const oldRequest = apiFetch('http://backend-a.test:3002', '/api/config'); + await Promise.resolve(); + rotateBackendRequestScope(); + resolveOld(new Response(JSON.stringify({ backend: 'a' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })); + + await expect(oldRequest).rejects.toBeInstanceOf(StaleBackendRequestError); + }); }); diff --git a/src/services/api.ts b/src/services/api.ts index b7a197e..2aa032e 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,5 +1,15 @@ const API_TOKEN_KEY = 'streamvault_auth_token'; +export const BACKEND_UNAVAILABLE_MESSAGE = + 'Cannot reach the StreamVault backend. Check the StreamVault Server URL and make sure the backend is running.'; + +export class BackendUnavailableError extends Error { + constructor(options?: ErrorOptions) { + super(BACKEND_UNAVAILABLE_MESSAGE, options); + this.name = 'BackendUnavailableError'; + } +} + export class ApiError extends Error { readonly status: number; @@ -10,6 +20,22 @@ export class ApiError extends Error { } } +export class StaleBackendRequestError extends Error { + constructor() { + super('Backend changed while the request was in progress'); + this.name = 'StaleBackendRequestError'; + } +} + +let backendRequestGeneration = 0; +let backendRequestController = typeof AbortController === 'undefined' ? null : new AbortController(); + +export function rotateBackendRequestScope(): void { + backendRequestGeneration++; + backendRequestController?.abort(); + backendRequestController = typeof AbortController === 'undefined' ? null : new AbortController(); +} + function getApiToken(): string { try { const raw = localStorage.getItem(API_TOKEN_KEY); @@ -30,6 +56,54 @@ export function hasStoredApiToken(): boolean { return getApiToken().length > 0; } +export function clearStoredApiToken(): void { + try { + localStorage.removeItem(API_TOKEN_KEY); + } catch { + // Storage can be unavailable in privacy-restricted runtimes. + } +} + +export function setStoredApiToken(token: string): void { + try { + if (token) localStorage.setItem(API_TOKEN_KEY, JSON.stringify(token)); + else localStorage.removeItem(API_TOKEN_KEY); + } catch { + // Storage can be unavailable in privacy-restricted runtimes. + } +} + +async function fetchBackend(url: string, options?: RequestInit): Promise { + const generation = backendRequestGeneration; + const signal = options?.signal ?? backendRequestController?.signal; + try { + const response = await fetch(url, { ...options, signal }); + if (generation !== backendRequestGeneration) throw new StaleBackendRequestError(); + return response; + } catch (error) { + if (generation !== backendRequestGeneration) throw new StaleBackendRequestError(); + if (error instanceof DOMException && error.name === 'AbortError') throw error; + throw new BackendUnavailableError({ cause: error }); + } +} + +async function parseJsonResponse(response: Response): Promise { + if (!response.ok) throw new ApiError(response.status, response.statusText); + const text = await response.text(); + return (text ? JSON.parse(text) : undefined) as T; +} + +/** Probe a user-entered backend without forwarding credentials for another origin. */ +// The status endpoint is intentionally public and returns heterogeneous status fields. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export async function probeBackend(baseUrl: string, signal?: AbortSignal): Promise { + const generation = backendRequestGeneration; + const response = await fetchBackend(`${baseUrl}/api/status`, { signal, cache: 'no-store' }); + const data = await parseJsonResponse(response); + if (generation !== backendRequestGeneration) throw new StaleBackendRequestError(); + return data; +} + /** Fetch JSON from StreamVault with the protected-endpoint token in a header. */ // Existing endpoints return heterogeneous JSON; callers may opt into a precise T. // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -37,20 +111,15 @@ export async function apiFetch( baseUrl: string, path: string, options?: RequestInit, + tokenOverride?: string | null, ): Promise { + const generation = backendRequestGeneration; const headers = new Headers(options?.headers); - const token = getApiToken(); + const token = tokenOverride === undefined ? getApiToken() : tokenOverride || ''; if (token && !headers.has('x-streamvault-token')) headers.set('x-streamvault-token', token); - const response = await fetch(`${baseUrl}${path}`, { ...options, headers }); - if (!response.ok) { - throw new ApiError(response.status, response.statusText); - } - - if (response.status === 204 || response.status === 202) { - const text = await response.text(); - return (text ? JSON.parse(text) : undefined) as T; - } - const text = await response.text(); - return (text ? JSON.parse(text) : undefined) as T; + const response = await fetchBackend(`${baseUrl}${path}`, { ...options, headers }); + const data = await parseJsonResponse(response); + if (generation !== backendRequestGeneration) throw new StaleBackendRequestError(); + return data; } diff --git a/src/services/channel-service.ts b/src/services/channel-service.ts index 7db597d..088bbf9 100644 --- a/src/services/channel-service.ts +++ b/src/services/channel-service.ts @@ -158,6 +158,11 @@ export function clearAllWatchProgress(): void { setItem(WATCH_PROGRESS_KEY, {}); } +export function clearBackendWatchState(): void { + clearRecentChannels(); + clearAllWatchProgress(); +} + // --- Subtitles Preference --- export function getSubtitlesEnabled(): boolean { diff --git a/src/stores/backendReset.test.ts b/src/stores/backendReset.test.ts new file mode 100644 index 0000000..767ef13 --- /dev/null +++ b/src/stores/backendReset.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { resetBackendScopedStores } from './backendReset'; +import { useAppStore } from './appStore'; +import { usePlayerStore } from './playerStore'; +import { useRecordingStore } from './recordingStore'; +import { useFavoritesStore } from './favoritesStore'; + +const { stopActivePlayback } = vi.hoisted(() => ({ stopActivePlayback: vi.fn() })); + +vi.mock('../hooks/usePlayer', () => ({ stopActivePlayback })); + +describe('resetBackendScopedStores', () => { + beforeEach(() => { + stopActivePlayback.mockClear(); + useAppStore.setState({ + currentView: 'settings', + viewStack: ['channels'], + selectedGroup: 'Sports', + selectedSeries: { id: 'series-1' } as never, + selectedMovie: { id: 'movie-1' } as never, + selectedRecordingId: 'recording-1', + navigationBlocker: () => true, + browseStates: { channels: { searchQuery: 'news', selectedGroup: 'News' } }, + visitedViews: { channels: true, settings: true }, + }); + usePlayerStore.setState({ + currentChannel: { id: 'channel-1' } as never, + status: 'playing', + errorMessage: 'old error', + groupChannels: [{ id: 'channel-1' } as never], + groupChannelsLoading: true, + channelListVisible: false, + audioOnly: true, + }); + useRecordingStore.setState({ + recordings: [{ id: 'recording-1' } as never], + rules: [{ id: 'rule-1' } as never], + status: {} as never, + commercialSegments: { 'recording-1': {} as never }, + commercialSegmentsLoading: { 'recording-1': true }, + commercialSegmentsError: { 'recording-1': 'old error' }, + loading: true, + }); + useFavoritesStore.setState({ + favoriteIds: new Set(['channel-1']), + lists: [{ id: 'list-1', name: 'Old', channelIds: ['channel-1'] }], + }); + localStorage.setItem('streamvault_favorites', JSON.stringify(['channel-1'])); + localStorage.setItem('streamvault_favorite_lists', JSON.stringify([{ id: 'list-1' }])); + localStorage.setItem('streamvault_recent_channels', JSON.stringify(['channel-1'])); + localStorage.setItem('streamvault_last_watched', JSON.stringify('channel-1')); + localStorage.setItem('streamvault_watch_progress', JSON.stringify({ 'channel-1': { position: 60 } })); + }); + + it('still clears stores if transport cleanup throws', () => { + stopActivePlayback.mockImplementationOnce(() => { throw new Error('cleanup failed'); }); + + expect(() => resetBackendScopedStores()).not.toThrow(); + expect(usePlayerStore.getState().currentChannel).toBeNull(); + expect(useRecordingStore.getState().recordings).toEqual([]); + expect(useAppStore.getState().selectedSeries).toBeNull(); + }); + + it('clears every in-memory value tied to the previous backend', () => { + resetBackendScopedStores(true); + + expect(stopActivePlayback).toHaveBeenCalledOnce(); + + expect(useAppStore.getState()).toMatchObject({ + viewStack: [], + selectedGroup: null, + selectedSeries: null, + selectedMovie: null, + selectedRecordingId: null, + navigationBlocker: null, + browseStates: {}, + visitedViews: { settings: true }, + }); + expect(usePlayerStore.getState()).toMatchObject({ + currentChannel: null, + status: 'idle', + errorMessage: '', + groupChannels: [], + groupChannelsLoading: false, + channelListVisible: true, + audioOnly: false, + }); + expect(useRecordingStore.getState()).toMatchObject({ + recordings: [], + rules: [], + status: null, + commercialSegments: {}, + commercialSegmentsLoading: {}, + commercialSegmentsError: {}, + loading: false, + }); + expect(useFavoritesStore.getState().favoriteIds.size).toBe(0); + expect(useFavoritesStore.getState().lists).toEqual([]); + expect(localStorage.getItem('streamvault_favorites')).toBe(JSON.stringify([])); + expect(localStorage.getItem('streamvault_favorite_lists')).toBe(JSON.stringify([])); + expect(localStorage.getItem('streamvault_recent_channels')).toBe(JSON.stringify([])); + expect(localStorage.getItem('streamvault_last_watched')).toBe('null'); + expect(localStorage.getItem('streamvault_watch_progress')).toBe(JSON.stringify({})); + }); +}); diff --git a/src/stores/backendReset.ts b/src/stores/backendReset.ts new file mode 100644 index 0000000..1ee18ab --- /dev/null +++ b/src/stores/backendReset.ts @@ -0,0 +1,33 @@ +import { useAppStore } from './appStore'; +import { resetPlayerBackendState } from './playerStore'; +import { resetRecordingBackendState } from './recordingStore'; +import { stopActivePlayback } from '../hooks/usePlayer'; +import { resetFavoritesBackendState } from './favoritesStore'; +import { clearBackendWatchState } from '../services/channel-service'; + +export function resetBackendScopedStores(clearPersistentData = false): void { + const { currentView } = useAppStore.getState(); + + try { + stopActivePlayback(); + } catch { + // Store cleanup must still complete if a platform transport throws. + } finally { + resetPlayerBackendState(); + resetRecordingBackendState(); + if (clearPersistentData) { + resetFavoritesBackendState(); + clearBackendWatchState(); + } + useAppStore.setState({ + viewStack: [], + selectedGroup: null, + selectedSeries: null, + selectedMovie: null, + selectedRecordingId: null, + navigationBlocker: null, + browseStates: {}, + visitedViews: { [currentView]: true }, + }); + } +} diff --git a/src/stores/channelStore.test.ts b/src/stores/channelStore.test.ts index 58cf5f0..2c0627c 100644 --- a/src/stores/channelStore.test.ts +++ b/src/stores/channelStore.test.ts @@ -1,11 +1,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useChannelStore } from './channelStore'; +import { rotateBackendRequestScope } from '../services/api'; describe('commercial auto-skip config', () => { beforeEach(() => { localStorage.clear(); vi.restoreAllMocks(); + rotateBackendRequestScope(); useChannelStore.setState({ + apiBaseUrl: '', + backendConnection: 'disconnected', + backendGeneration: 0, + _hydrated: false, inputMode: 'manual', playlistUrl: 'https://example.test/list.m3u', epgUrl: 'https://example.test/guide.xml', @@ -58,4 +64,383 @@ describe('commercial auto-skip config', () => { expect(useChannelStore.getState().commercialAutoSkip).toBe(false); expect(useChannelStore.getState().error).toContain('500'); }); + + it('preserves the active backend when a candidate status probe fails', async () => { + localStorage.setItem('streamvault_api_url', JSON.stringify('http://old-backend.test:3002')); + localStorage.setItem('streamvault_auth_token', JSON.stringify('old-backend-secret')); + useChannelStore.setState({ + apiBaseUrl: 'http://old-backend.test:3002', + backendConnection: 'connected', + error: null, + }); + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to fetch')); + + await expect(useChannelStore.getState().connectBackend('http://candidate.test:3002')) + .resolves.toBe(false); + + expect(localStorage.getItem('streamvault_api_url')).toBe(JSON.stringify('http://old-backend.test:3002')); + expect(localStorage.getItem('streamvault_auth_token')).toBe(JSON.stringify('old-backend-secret')); + expect(useChannelStore.getState()).toMatchObject({ + apiBaseUrl: 'http://old-backend.test:3002', + backendConnection: 'connected', + xtreamCredentials: { serverUrl: 'https://provider.test', username: 'user', password: 'pass' }, + error: 'Cannot reach the StreamVault backend. Check the StreamVault Server URL and make sure the backend is running.', + }); + }); + + it('rolls back a candidate whose bootstrap requests fail after a successful probe', async () => { + localStorage.setItem('streamvault_api_url', JSON.stringify('http://old-backend.test:3002')); + localStorage.setItem('streamvault_auth_token', JSON.stringify('old-backend-secret')); + useChannelStore.setState({ + apiBaseUrl: 'http://old-backend.test:3002', + backendConnection: 'connected', + error: null, + }); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + if (url.includes('/api/config')) { + return new Response('nope', { status: 500, statusText: 'Broken' }); + } + const body = url.includes('/api/programs') ? { programs: [] } : { isSyncing: false }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + + await expect(useChannelStore.getState().connectBackend('http://candidate.test:3002')) + .resolves.toBe(false); + + expect(localStorage.getItem('streamvault_api_url')).toBe(JSON.stringify('http://old-backend.test:3002')); + expect(localStorage.getItem('streamvault_auth_token')).toBe(JSON.stringify('old-backend-secret')); + expect(useChannelStore.getState()).toMatchObject({ + apiBaseUrl: 'http://old-backend.test:3002', + backendConnection: 'connected', + playlistUrl: 'https://example.test/list.m3u', + epgUrl: 'https://example.test/guide.xml', + xtreamCredentials: { serverUrl: 'https://provider.test', username: 'user', password: 'pass' }, + error: 'API error: 500 Broken', + }); + }); + + it('persists a backend URL and unlocks provider settings only after hydration succeeds', async () => { + localStorage.setItem('streamvault_auth_token', JSON.stringify('old-backend-secret')); + useChannelStore.setState({ apiBaseUrl: 'http://old-backend.test:3002' }); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + const body = url.includes('/api/config') + ? {} + : url.includes('/api/programs') + ? { programs: [] } + : { isSyncing: false, contentTypeCounts: {} }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + + await expect(useChannelStore.getState().connectBackend('http://192.168.1.20:3002')) + .resolves.toBe(true); + + expect(localStorage.getItem('streamvault_api_url')).toBe(JSON.stringify('http://192.168.1.20:3002')); + expect(localStorage.getItem('streamvault_auth_token')).toBeNull(); + for (const [, init] of fetchMock.mock.calls) { + expect(new Headers(init?.headers).has('x-streamvault-token')).toBe(false); + } + expect(useChannelStore.getState()).toMatchObject({ + apiBaseUrl: 'http://192.168.1.20:3002', + backendConnection: 'connected', + playlistUrl: '', + epgUrl: '', + xtreamCredentials: { serverUrl: '', username: '', password: '' }, + error: null, + _hydrated: true, + }); + }); + + it('uses only the candidate token while onboarding an auth-protected backend', async () => { + localStorage.setItem('streamvault_auth_token', JSON.stringify('old-backend-token')); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const url = String(input); + const token = new Headers(init?.headers).get('x-streamvault-token'); + if (url.endsWith('/api/status')) { + expect(token).toBeNull(); + return new Response(JSON.stringify({ phase: 'idle', isSyncing: false, isCrawling: false }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + expect(token).toBe('new-backend-token'); + const body = url.endsWith('/api/config') + ? { inputMode: 'manual', playlistUrl: 'https://new/list.m3u' } + : { programs: [] }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + + await expect(useChannelStore.getState().connectBackend( + 'http://protected.test:3002', + 'new-backend-token', + )).resolves.toBe(true); + + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(localStorage.getItem('streamvault_auth_token')).toBe(JSON.stringify('new-backend-token')); + expect(useChannelStore.getState()).toMatchObject({ + apiBaseUrl: 'http://protected.test:3002', + backendConnection: 'connected', + playlistUrl: 'https://new/list.m3u', + }); + }); + + it('keeps provider settings locked when startup bootstrap is only partially successful', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + if (url.endsWith('/api/status')) { + return new Response(JSON.stringify({ phase: 'idle', isSyncing: false }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (url.endsWith('/api/config')) return new Response('', { status: 500, statusText: 'Broken' }); + return new Response(JSON.stringify({ programs: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + useChannelStore.setState({ + apiBaseUrl: 'http://backend.test:3002', + backendConnection: 'unknown', + error: null, + _hydrated: false, + }); + + await useChannelStore.getState().hydrate(); + + expect(useChannelStore.getState()).toMatchObject({ + backendConnection: 'disconnected', + error: 'API error: 500 Broken', + _hydrated: true, + }); + }); + + it('ignores a failed hydration after the user has selected another backend', async () => { + let rejectOldBackend!: (reason: unknown) => void; + vi.spyOn(globalThis, 'fetch').mockReturnValue(new Promise((_resolve, reject) => { + rejectOldBackend = reject; + })); + useChannelStore.setState({ + apiBaseUrl: 'http://old-backend.test:3002', + backendConnection: 'unknown', + error: null, + _hydrated: false, + }); + + const oldHydration = useChannelStore.getState().hydrate(); + await Promise.resolve(); + useChannelStore.setState({ + apiBaseUrl: 'http://new-backend.test:3002', + backendConnection: 'connected', + backendGeneration: 1, + error: null, + _hydrated: true, + }); + rejectOldBackend(new TypeError('Failed to fetch')); + await oldHydration; + + expect(useChannelStore.getState()).toMatchObject({ + apiBaseUrl: 'http://new-backend.test:3002', + backendConnection: 'connected', + error: null, + _hydrated: true, + }); + }); + + it('ignores a stale program-fetch failure after the backend changes', async () => { + let rejectOldBackend!: (reason: unknown) => void; + vi.spyOn(globalThis, 'fetch').mockReturnValue(new Promise((_resolve, reject) => { + rejectOldBackend = reject; + })); + useChannelStore.setState({ + apiBaseUrl: 'http://old-backend.test:3002', + error: null, + }); + + const oldPrograms = useChannelStore.getState().fetchPrograms(); + await Promise.resolve(); + useChannelStore.setState({ apiBaseUrl: 'http://new-backend.test:3002', backendGeneration: 1, error: null }); + rejectOldBackend(new TypeError('Failed to fetch')); + await oldPrograms; + + expect(useChannelStore.getState()).toMatchObject({ + apiBaseUrl: 'http://new-backend.test:3002', + error: null, + }); + }); + + it('rejects an ABA response from an earlier generation of the same backend URL', async () => { + let resolveFirstA!: (response: Response) => void; + vi.spyOn(globalThis, 'fetch').mockReturnValue(new Promise((resolve) => { + resolveFirstA = resolve; + })); + useChannelStore.setState({ + apiBaseUrl: 'http://backend-a.test:3002', + backendGeneration: 0, + programs: [], + error: null, + }); + + const firstA = useChannelStore.getState().fetchPrograms(); + await Promise.resolve(); + useChannelStore.setState({ apiBaseUrl: 'http://backend-b.test:3002', backendGeneration: 1 }); + useChannelStore.setState({ apiBaseUrl: 'http://backend-a.test:3002', backendGeneration: 2 }); + resolveFirstA(new Response(JSON.stringify({ + programs: [{ + channelId: 'stale', title: 'Stale', description: '', + start: '2026-01-01T00:00:00Z', stop: '2026-01-01T01:00:00Z', category: '', + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + await firstA; + + expect(useChannelStore.getState().programs).toEqual([]); + }); + + it('does not return channels from a backend generation that changed during lookup', async () => { + useChannelStore.setState({ apiBaseUrl: 'http://backend-a.test:3002', backendGeneration: 0 }); + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + useChannelStore.setState({ apiBaseUrl: 'http://backend-b.test:3002', backendGeneration: 1 }); + return new Response(JSON.stringify({ channels: [{ id: 'old-channel' }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + + await expect(useChannelStore.getState().fetchChannelsByIds(['old-channel'])).resolves.toEqual([]); + }); + + it('does not merge EPG data from a backend generation that changed during lookup', async () => { + useChannelStore.setState({ + apiBaseUrl: 'http://backend-a.test:3002', + backendGeneration: 0, + programsByChannel: new Map(), + }); + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + useChannelStore.setState({ apiBaseUrl: 'http://backend-b.test:3002', backendGeneration: 1 }); + return new Response(JSON.stringify({ + programs: [{ + channelId: 'old-channel', title: 'Old show', description: '', + start: '2026-01-01T00:00:00Z', stop: '2026-01-01T01:00:00Z', category: '', + }], + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + await expect(useChannelStore.getState().fetchEpgForStream(1)).resolves.toEqual([]); + expect(useChannelStore.getState().programsByChannel.size).toBe(0); + }); + + it('ignores a stale config save after the backend changes', async () => { + let resolveOldSave!: (response: Response) => void; + vi.spyOn(globalThis, 'fetch').mockReturnValue(new Promise((resolve) => { + resolveOldSave = resolve; + })); + useChannelStore.setState({ + apiBaseUrl: 'http://backend-a.test:3002', + backendGeneration: 0, + backendConnection: 'connected', + inputMode: 'manual', + error: null, + }); + + const oldSave = useChannelStore.getState().saveConfig({ inputMode: 'xtream' }); + await Promise.resolve(); + rotateBackendRequestScope(); + useChannelStore.setState({ + apiBaseUrl: 'http://backend-b.test:3002', + backendGeneration: 1, + backendConnection: 'connected', + inputMode: 'manual', + error: null, + }); + resolveOldSave(new Response(null, { status: 204 })); + + await expect(oldSave).resolves.toBe(false); + expect(useChannelStore.getState()).toMatchObject({ + apiBaseUrl: 'http://backend-b.test:3002', + backendConnection: 'connected', + inputMode: 'manual', + error: null, + }); + }); + + it('ignores a stale crawl failure after the backend changes', async () => { + let resolveOldCrawl!: (response: Response) => void; + vi.spyOn(globalThis, 'fetch').mockReturnValue(new Promise((resolve) => { + resolveOldCrawl = resolve; + })); + useChannelStore.setState({ + apiBaseUrl: 'http://backend-a.test:3002', + backendGeneration: 0, + isCrawling: false, + crawlProgress: '', + }); + + const oldCrawl = useChannelStore.getState().triggerCrawl(); + await Promise.resolve(); + rotateBackendRequestScope(); + useChannelStore.setState({ + apiBaseUrl: 'http://backend-b.test:3002', + backendGeneration: 1, + isCrawling: true, + crawlProgress: 'New backend crawl', + }); + resolveOldCrawl(new Response(null, { status: 202 })); + await oldCrawl; + + expect(useChannelStore.getState()).toMatchObject({ + apiBaseUrl: 'http://backend-b.test:3002', + isCrawling: true, + crawlProgress: 'New backend crawl', + }); + }); + + it('ignores status polling results from the previous backend generation', async () => { + let resolveOldPoll!: (response: Response) => void; + vi.spyOn(globalThis, 'fetch').mockReturnValue(new Promise((resolve) => { + resolveOldPoll = resolve; + })); + useChannelStore.setState({ + apiBaseUrl: 'http://backend-a.test:3002', + backendGeneration: 0, + isLoading: false, + isCrawling: false, + channelCount: 7, + }); + + const oldPoll = useChannelStore.getState().pollStatus(); + await Promise.resolve(); + useChannelStore.setState({ + apiBaseUrl: 'http://backend-b.test:3002', + backendGeneration: 1, + isLoading: false, + isCrawling: false, + channelCount: 3, + }); + resolveOldPoll(new Response(JSON.stringify({ + phase: 'parsing', + message: 'Old backend syncing', + isSyncing: true, + isCrawling: true, + channelCount: 999, + }), { status: 200, headers: { 'Content-Type': 'application/json' } })); + await oldPoll; + + expect(useChannelStore.getState()).toMatchObject({ + apiBaseUrl: 'http://backend-b.test:3002', + backendGeneration: 1, + isLoading: false, + isCrawling: false, + channelCount: 3, + }); + }); }); diff --git a/src/stores/channelStore.ts b/src/stores/channelStore.ts index c2907eb..f89ebf8 100644 --- a/src/stores/channelStore.ts +++ b/src/stores/channelStore.ts @@ -2,13 +2,15 @@ import { create } from 'zustand'; import type { Channel, Program, Category, SeriesInfo, MovieInfo } from '../types'; import { getItem, setItem } from '../utils/storage'; import { useAppStore } from './appStore'; -import { apiFetch } from '../services/api'; +import { apiFetch, BackendUnavailableError, clearStoredApiToken, probeBackend, rotateBackendRequestScope, setStoredApiToken } from '../services/api'; +import { usesSameOriginApi } from '../utils/server-connection'; const toast = (msg: string) => useAppStore.getState().showToastMessage(msg); export type InputMode = 'xtream' | 'manual'; export type SyncInterval = 'startup' | '6h' | '12h' | '24h' | 'manual'; export type LoadingPhase = 'idle' | 'fetching-playlist' | 'parsing-playlist' | 'fetching-epg' | 'parsing-epg' | 'done' | 'error'; +export type BackendConnection = 'unknown' | 'connected' | 'disconnected'; export interface XtreamCredentials { serverUrl: string; @@ -46,10 +48,62 @@ interface ChannelState { crawlProgress: string; lastCrawlTime: number; apiBaseUrl: string; + backendConnection: BackendConnection; + backendGeneration: number; commercialAutoSkip: boolean; _hydrated: boolean; } +interface RawProgram { + channelId: string; + title: string; + description: string; + start: string; + stop: string; + category: string; +} + +interface BackendStatusPayload { + phase?: LoadingPhase; + message?: string; + channelCount?: number; + lastSyncTime?: number; + isSyncing?: boolean; + isCrawling?: boolean; + crawlProgress?: string; + lastCrawlTime?: number; + contentTypeCounts?: Record; +} + +function parsePrograms(raw: unknown): Program[] { + if (!Array.isArray(raw)) return []; + return (raw as RawProgram[]).map((program) => ({ + channelId: program.channelId, + title: program.title, + description: program.description, + start: new Date(program.start), + stop: new Date(program.stop), + category: program.category, + })); +} + +function configPatch(data: Record, current: ChannelState): Partial { + return { + inputMode: typeof data.inputMode === 'string' ? data.inputMode as InputMode : current.inputMode, + playlistUrl: typeof data.playlistUrl === 'string' ? data.playlistUrl : current.playlistUrl, + epgUrl: typeof data.epgUrl === 'string' ? data.epgUrl : current.epgUrl, + xtreamCredentials: { + serverUrl: typeof data.xtreamServer === 'string' ? data.xtreamServer : current.xtreamCredentials.serverUrl, + username: typeof data.xtreamUsername === 'string' ? data.xtreamUsername : current.xtreamCredentials.username, + password: typeof data.xtreamPassword === 'string' ? data.xtreamPassword : current.xtreamCredentials.password, + }, + syncInterval: typeof data.syncInterval === 'string' ? data.syncInterval as SyncInterval : current.syncInterval, + commercialAutoSkip: typeof data.commercialAutoSkip === 'boolean' + ? data.commercialAutoSkip + : current.commercialAutoSkip, + }; +} + function buildProgramIndex(programs: Program[]): Map { const index = new Map(); for (const p of programs) { @@ -70,31 +124,41 @@ declare const __SERVER_URL__: string; const API_BASE_URL_KEY = 'streamvault_api_url'; const DEFAULT_SERVER_URL: string = typeof __SERVER_URL__ !== 'undefined' ? __SERVER_URL__ : ''; -// When served from the same origin (PWA), API is always available via relative paths -export const SAME_ORIGIN = !DEFAULT_SERVER_URL; +// Hosted PWA/Docker builds use relative API paths. A local Tizen widget with an +// empty build-time URL must instead ask the user for its backend address. +export const SAME_ORIGIN = usesSameOriginApi(DEFAULT_SERVER_URL, window.location.protocol); +const INITIAL_API_BASE_URL = SAME_ORIGIN ? '' : getItem(API_BASE_URL_KEY, DEFAULT_SERVER_URL); /** Check if API is reachable — same-origin always works, remote needs a URL */ function hasApi(apiBaseUrl: string): boolean { return SAME_ORIGIN || !!apiBaseUrl; } +function backendOrigin(apiBaseUrl: string): string { + try { + return new URL(apiBaseUrl || window.location.origin, window.location.href).origin; + } catch { + return ''; + } +} + interface ChannelActions { - setApiBaseUrl: (url: string) => void; + connectBackend: (url: string, token?: string) => Promise; fetchCategories: (contentType: string) => Promise; - fetchChannels: (group?: string) => Promise; + fetchChannels: (group?: string, expectedGeneration?: number) => Promise; fetchChannelsByIds: (ids: string[]) => Promise; fetchMoreChannels: () => Promise; - fetchPrograms: () => Promise; + fetchPrograms: (expectedBaseUrl?: string, expectedGeneration?: number) => Promise; fetchEpgForStream: (streamId: number) => Promise; searchChannels: (query: string, contentType?: string, group?: string) => Promise; fetchSeriesInfo: (seriesId: number) => Promise; fetchMovieInfo: (vodId: number) => Promise; - fetchConfig: () => Promise; + fetchConfig: (expectedBaseUrl?: string, expectedGeneration?: number) => Promise; saveConfig: (config: Record) => Promise; triggerSync: () => Promise; cancelSync: () => void; triggerCrawl: () => Promise; cancelCrawl: () => void; - pollStatus: () => Promise; + pollStatus: (expectedGeneration?: number) => Promise; setSelectedGroup: (group: string) => void; setSelectedRegion: (region: string) => void; hydrate: () => Promise; @@ -103,6 +167,7 @@ interface ChannelActions { let pollInterval: ReturnType | null = null; let fetchAbortController: AbortController | null = null; +let connectionAttempt = 0; function stopPolling() { if (pollInterval) { @@ -138,32 +203,127 @@ export const useChannelStore = create()((set, get isCrawling: false, crawlProgress: '', lastCrawlTime: 0, - apiBaseUrl: SAME_ORIGIN ? '' : getItem(API_BASE_URL_KEY, DEFAULT_SERVER_URL), + apiBaseUrl: INITIAL_API_BASE_URL, + backendConnection: hasApi(INITIAL_API_BASE_URL) ? 'unknown' : 'disconnected', + backendGeneration: 0, commercialAutoSkip: false, _hydrated: false, - setApiBaseUrl: (url: string) => { - set({ apiBaseUrl: url }); - setItem(API_BASE_URL_KEY, url); + connectBackend: async (url: string, token?: string) => { + const attempt = ++connectionAttempt; + const active = get(); + const switchingOrigins = backendOrigin(active.apiBaseUrl) !== backendOrigin(url); + try { + const status = await probeBackend(url); + const now = Date.now(); + const to = now + 6 * 60 * 60 * 1000; + const candidateToken = token?.trim(); + const tokenOverride = candidateToken || (switchingOrigins ? null : undefined); + const [config, programData] = await Promise.all([ + apiFetch>(url, '/api/config', { cache: 'no-store' }, tokenOverride), + apiFetch<{ programs?: RawProgram[] }>( + url, + `/api/programs?from=${now - 2 * 60 * 60 * 1000}&to=${to}`, + { cache: 'no-store' }, + tokenOverride, + ), + ]); + if (attempt !== connectionAttempt) return false; + + const current = get(); + const nextGeneration = current.backendGeneration + 1; + const fallback = switchingOrigins + ? { + ...current, + playlistUrl: '', + epgUrl: '', + inputMode: 'xtream' as const, + xtreamCredentials: { serverUrl: '', username: '', password: '' }, + } + : current; + const programs = parsePrograms(programData.programs); + + stopPolling(); + fetchAbortController?.abort(); + fetchAbortController = null; + rotateBackendRequestScope(); + if (candidateToken) setStoredApiToken(candidateToken); + else if (switchingOrigins) clearStoredApiToken(); + setItem(API_BASE_URL_KEY, url); + set({ + ...configPatch(config, fallback), + apiBaseUrl: url, + backendConnection: 'connected', + backendGeneration: nextGeneration, + error: null, + _hydrated: true, + channels: [], + programs, + programsByChannel: buildProgramIndex(programs), + categoriesByType: {}, + groups: ['All'], + regions: ['All'], + selectedGroup: 'All', + selectedRegion: 'All', + contentTypeCounts: status.contentTypeCounts || {}, + channelCount: status.channelCount || 0, + channelTotal: 0, + hasMore: false, + nextCursor: null, + isLoading: status.isSyncing === true, + loadingPhase: status.phase || 'idle', + loadingMessage: status.message || '', + lastSyncTime: status.lastSyncTime || 0, + isCrawling: status.isCrawling === true, + crawlProgress: status.crawlProgress || '', + lastCrawlTime: status.lastCrawlTime || 0, + }); + + if (status.isSyncing || status.isCrawling) { + let pollDelay = status.isSyncing ? 2000 : 5000; + const schedulePoll = () => { + pollInterval = setTimeout(() => { + get().pollStatus(nextGeneration).then(() => { + const latest = get(); + if (latest.backendGeneration !== nextGeneration) return; + if (latest.isLoading || latest.isCrawling) { + pollDelay = Math.min(pollDelay * 1.5, 10000); + schedulePoll(); + } + }); + }, pollDelay); + }; + schedulePoll(); + } + return true; + } catch (err) { + if (attempt !== connectionAttempt) return false; + const msg = err instanceof Error ? err.message : 'Cannot connect to the StreamVault backend'; + set({ error: msg }); + return false; + } }, fetchCategories: async (contentType: string) => { - const { apiBaseUrl } = get(); + const { apiBaseUrl, backendGeneration } = get(); if (!hasApi(apiBaseUrl)) return; try { const data = await apiFetch(apiBaseUrl, `/api/categories?type=${encodeURIComponent(contentType)}`); + if (get().backendGeneration !== backendGeneration) return; const list: Category[] = data.categories || []; set((state) => ({ categoriesByType: { ...state.categoriesByType, [contentType]: list }, })); } catch (err) { + if (get().backendGeneration !== backendGeneration) return; const msg = err instanceof Error ? err.message : 'Failed to fetch categories'; set({ error: msg }); } }, - fetchChannels: async (group?: string) => { + fetchChannels: async (group?: string, expectedGeneration?: number) => { const { apiBaseUrl } = get(); + const generation = expectedGeneration ?? get().backendGeneration; if (!hasApi(apiBaseUrl)) return; // Abort any in-flight channel fetch if (fetchAbortController) fetchAbortController.abort(); @@ -177,7 +337,7 @@ export const useChannelStore = create()((set, get } const qs = params.toString(); const data = await apiFetch(apiBaseUrl, `/api/channels${qs ? '?' + qs : ''}`, { signal }); - if (signal.aborted) return; + if (signal.aborted || get().backendGeneration !== generation) return; const channels: Channel[] = data.channels; const total: number = data.total ?? channels.length; set({ @@ -192,13 +352,14 @@ export const useChannelStore = create()((set, get }); } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return; + if (get().backendGeneration !== generation) return; const msg = err instanceof Error ? err.message : 'Failed to fetch channels'; set({ error: msg }); } }, fetchChannelsByIds: async (ids: string[]) => { - const { apiBaseUrl } = get(); + const { apiBaseUrl, backendGeneration } = get(); if (!hasApi(apiBaseUrl) || ids.length === 0) return []; try { const data = await apiFetch(apiBaseUrl, '/api/channels/by-ids', { @@ -206,15 +367,17 @@ export const useChannelStore = create()((set, get headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }), }); + if (get().backendGeneration !== backendGeneration) return []; return (data.channels ?? []) as Channel[]; } catch (err) { + if (get().backendGeneration !== backendGeneration) return []; toast(`Failed to fetch channels: ${err}`); return []; } }, fetchMoreChannels: async () => { - const { apiBaseUrl, channels, channelTotal, hasMore, selectedGroup, nextCursor } = get(); + const { apiBaseUrl, backendGeneration, channels, channelTotal, hasMore, selectedGroup, nextCursor } = get(); if (!hasApi(apiBaseUrl) || !hasMore || !nextCursor) return; if (!selectedGroup || selectedGroup === 'All') return; try { @@ -225,6 +388,7 @@ export const useChannelStore = create()((set, get cursorName: nextCursor.name, }); const data = await apiFetch(apiBaseUrl, `/api/channels?${params}`); + if (get().backendGeneration !== backendGeneration) return; const newChannels: Channel[] = data.channels; const merged = [...channels, ...newChannels]; const total: number = data.total ?? channelTotal; @@ -236,41 +400,41 @@ export const useChannelStore = create()((set, get nextCursor: data.nextCursor || null, }); } catch (err) { + if (get().backendGeneration !== backendGeneration) return; const msg = err instanceof Error ? err.message : 'Failed to load more'; set({ error: msg }); } }, - fetchPrograms: async () => { - const { apiBaseUrl } = get(); + fetchPrograms: async (expectedBaseUrl?: string, expectedGeneration?: number) => { + const apiBaseUrl = expectedBaseUrl ?? get().apiBaseUrl; + const generation = expectedGeneration ?? get().backendGeneration; + const isCurrent = () => get().backendGeneration === generation; if (!hasApi(apiBaseUrl)) return; try { const now = Date.now(); const to = now + 6 * 60 * 60 * 1000; - const data = await apiFetch(apiBaseUrl, `/api/programs?from=${now - 2 * 60 * 60 * 1000}&to=${to}`); - const programs: Program[] = data.programs.map((p: { channelId: string; title: string; description: string; start: string; stop: string; category: string }) => ({ - channelId: p.channelId, - title: p.title, - description: p.description, - start: new Date(p.start), - stop: new Date(p.stop), - category: p.category, - })); + const data = await apiFetch<{ programs?: RawProgram[] }>(apiBaseUrl, `/api/programs?from=${now - 2 * 60 * 60 * 1000}&to=${to}`); + if (!isCurrent()) return; + const programs = parsePrograms(data.programs); set({ programs, programsByChannel: buildProgramIndex(programs), }); } catch (err) { + if (!isCurrent()) return; const msg = err instanceof Error ? err.message : 'Failed to fetch programs'; set({ error: msg }); + throw err; } }, fetchEpgForStream: async (streamId: number) => { - const { apiBaseUrl } = get(); + const { apiBaseUrl, backendGeneration } = get(); if (!hasApi(apiBaseUrl)) return []; try { const data = await apiFetch(apiBaseUrl, `/api/epg/${streamId}`); + if (get().backendGeneration !== backendGeneration) return []; const programs: Program[] = data.programs.map((p: { channelId: string; title: string; description: string; start: string; stop: string; category: string }) => ({ channelId: p.channelId, title: p.title, @@ -299,77 +463,75 @@ export const useChannelStore = create()((set, get set({ programsByChannel: newIndex }); return programs; } catch (err) { + if (get().backendGeneration !== backendGeneration) return []; toast(`Failed to fetch EPG: ${err}`); return []; } }, searchChannels: async (query: string, contentType?: string, group?: string) => { - const { apiBaseUrl } = get(); + const { apiBaseUrl, backendGeneration } = get(); if (!hasApi(apiBaseUrl) || !query.trim()) return []; try { const params = new URLSearchParams({ q: query }); if (contentType) params.set('type', contentType); if (group) params.set('group', group); const data = await apiFetch(apiBaseUrl, `/api/search?${params}`); + if (get().backendGeneration !== backendGeneration) return []; return data.channels as Channel[]; } catch (err) { + if (get().backendGeneration !== backendGeneration) return []; toast(`Search failed: ${err}`); return []; } }, fetchSeriesInfo: async (seriesId: number) => { - const { apiBaseUrl } = get(); + const { apiBaseUrl, backendGeneration } = get(); if (!hasApi(apiBaseUrl)) return null; try { const data = await apiFetch(apiBaseUrl, `/api/series/${seriesId}`); + if (get().backendGeneration !== backendGeneration) return null; return data as SeriesInfo; } catch (err) { + if (get().backendGeneration !== backendGeneration) return null; toast(`Failed to load series: ${err}`); return null; } }, fetchMovieInfo: async (vodId: number) => { - const { apiBaseUrl } = get(); + const { apiBaseUrl, backendGeneration } = get(); if (!hasApi(apiBaseUrl)) return null; try { const data = await apiFetch(apiBaseUrl, `/api/vod/${vodId}`); + if (get().backendGeneration !== backendGeneration) return null; return data as MovieInfo; } catch (err) { + if (get().backendGeneration !== backendGeneration) return null; toast(`Failed to load movie: ${err}`); return null; } }, - fetchConfig: async () => { - const { apiBaseUrl } = get(); + fetchConfig: async (expectedBaseUrl?: string, expectedGeneration?: number) => { + const apiBaseUrl = expectedBaseUrl ?? get().apiBaseUrl; + const generation = expectedGeneration ?? get().backendGeneration; + const isCurrent = () => get().backendGeneration === generation; if (!hasApi(apiBaseUrl)) return; try { - const data = await apiFetch(apiBaseUrl, '/api/config'); - const current = get(); - set({ - inputMode: typeof data.inputMode === 'string' ? data.inputMode as InputMode : current.inputMode, - playlistUrl: typeof data.playlistUrl === 'string' ? data.playlistUrl : current.playlistUrl, - epgUrl: typeof data.epgUrl === 'string' ? data.epgUrl : current.epgUrl, - xtreamCredentials: { - serverUrl: typeof data.xtreamServer === 'string' ? data.xtreamServer : current.xtreamCredentials.serverUrl, - username: typeof data.xtreamUsername === 'string' ? data.xtreamUsername : current.xtreamCredentials.username, - password: typeof data.xtreamPassword === 'string' ? data.xtreamPassword : current.xtreamCredentials.password, - }, - syncInterval: typeof data.syncInterval === 'string' ? data.syncInterval as SyncInterval : current.syncInterval, - commercialAutoSkip: typeof data.commercialAutoSkip === 'boolean' - ? data.commercialAutoSkip - : current.commercialAutoSkip, - }); + const data = await apiFetch>(apiBaseUrl, '/api/config'); + if (!isCurrent()) return; + set(configPatch(data, get())); } catch (err) { + if (!isCurrent()) return; toast(`Failed to fetch config: ${err}`); + throw err; } }, saveConfig: async (config: Record) => { - const { apiBaseUrl } = get(); + const { apiBaseUrl, backendGeneration } = get(); if (!hasApi(apiBaseUrl)) return false; try { await apiFetch(apiBaseUrl, '/api/config', { @@ -377,6 +539,7 @@ export const useChannelStore = create()((set, get headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config), }); + if (get().backendGeneration !== backendGeneration) return false; // Update local state to match if (typeof config.inputMode === 'string') set({ inputMode: config.inputMode as InputMode }); if (typeof config.playlistUrl === 'string') set({ playlistUrl: config.playlistUrl }); @@ -398,25 +561,31 @@ export const useChannelStore = create()((set, get set({ error: null }); return true; } catch (err) { + if (get().backendGeneration !== backendGeneration) return false; const msg = err instanceof Error ? err.message : 'Failed to save config'; - set({ error: msg }); + set({ + error: msg, + ...(err instanceof BackendUnavailableError ? { backendConnection: 'disconnected' as const } : {}), + }); return false; } }, triggerSync: async () => { - const { apiBaseUrl } = get(); + const { apiBaseUrl, backendGeneration } = get(); if (!hasApi(apiBaseUrl)) return; set({ isLoading: true, error: null, loadingPhase: 'fetching-playlist', loadingMessage: 'Starting sync...' }); try { await apiFetch(apiBaseUrl, '/api/sync', { method: 'POST' }); + if (get().backendGeneration !== backendGeneration) return; // Start polling for status with exponential backoff - get().pollStatus(); + get().pollStatus(backendGeneration); let pollDelay = 2000; const schedulePoll = () => { pollInterval = setTimeout(() => { - get().pollStatus().then(() => { - if (get().isLoading) { + get().pollStatus(backendGeneration).then(() => { + const current = get(); + if (current.backendGeneration === backendGeneration && current.isLoading) { pollDelay = Math.min(pollDelay * 1.5, 10000); schedulePoll(); } @@ -425,8 +594,15 @@ export const useChannelStore = create()((set, get }; schedulePoll(); } catch (err) { + if (get().backendGeneration !== backendGeneration) return; const msg = err instanceof Error ? err.message : 'Failed to start sync'; - set({ isLoading: false, error: msg, loadingPhase: 'idle', loadingMessage: '' }); + set({ + isLoading: false, + error: msg, + loadingPhase: 'idle', + loadingMessage: '', + ...(err instanceof BackendUnavailableError ? { backendConnection: 'disconnected' as const } : {}), + }); } }, @@ -439,12 +615,13 @@ export const useChannelStore = create()((set, get }, triggerCrawl: async () => { - const { apiBaseUrl } = get(); + const { apiBaseUrl, backendGeneration } = get(); if (!hasApi(apiBaseUrl)) return; set({ isCrawling: true, crawlProgress: 'Starting crawl...' }); try { await apiFetch(apiBaseUrl, '/api/crawl', { method: 'POST' }); } catch (err) { + if (get().backendGeneration !== backendGeneration) return; const msg = err instanceof Error ? err.message : 'Failed to start crawl'; set({ isCrawling: false, crawlProgress: msg }); } @@ -457,18 +634,22 @@ export const useChannelStore = create()((set, get set({ isCrawling: false, crawlProgress: 'Crawl cancelled' }); }, - pollStatus: async () => { + pollStatus: async (expectedGeneration?: number) => { const { apiBaseUrl } = get(); + const generation = expectedGeneration ?? get().backendGeneration; + const isCurrent = () => get().backendGeneration === generation; if (!hasApi(apiBaseUrl)) return; try { - const status = await apiFetch(apiBaseUrl, '/api/status'); + const status = await apiFetch(apiBaseUrl, '/api/status'); + if (!isCurrent()) return; set({ - loadingPhase: status.phase, - loadingMessage: status.message, - channelCount: status.channelCount, - lastSyncTime: status.lastSyncTime, - isLoading: status.isSyncing, - isCrawling: status.isCrawling, + backendConnection: 'connected', + loadingPhase: status.phase || 'idle', + loadingMessage: status.message || '', + channelCount: status.channelCount || 0, + lastSyncTime: status.lastSyncTime || 0, + isLoading: status.isSyncing === true, + isCrawling: status.isCrawling === true, crawlProgress: status.crawlProgress || '', lastCrawlTime: status.lastCrawlTime || 0, }); @@ -477,10 +658,14 @@ export const useChannelStore = create()((set, get stopPolling(); // Sync finished — fetch fresh data if (status.phase === 'done') { - await Promise.all([get().fetchChannels(), get().fetchPrograms()]); + await Promise.all([ + get().fetchChannels(undefined, generation), + get().fetchPrograms(apiBaseUrl, generation), + ]); } } } catch (err) { + if (!isCurrent()) return; toast(`Status poll failed: ${err}`); stopPolling(); set({ isLoading: false, loadingPhase: 'idle' }); @@ -495,27 +680,33 @@ export const useChannelStore = create()((set, get hydrate: async () => { if (get()._hydrated) return; - const { apiBaseUrl } = get(); + const { apiBaseUrl, backendGeneration } = get(); if (!SAME_ORIGIN && !apiBaseUrl) { - set({ _hydrated: true }); + set({ backendConnection: 'disconnected', _hydrated: true }); return; } + const isCurrentBackend = () => get().backendGeneration === backendGeneration; try { + // A configured URL is not a connection. Probe the backend before + // revealing provider credentials or attempting any other API work. + const status = await probeBackend(apiBaseUrl); + if (!isCurrentBackend()) return; + // Fetch config and programs (NOT all channels — ChannelList loads on demand) await Promise.all([ - get().fetchConfig(), - get().fetchPrograms(), + get().fetchConfig(apiBaseUrl, backendGeneration), + get().fetchPrograms(apiBaseUrl, backendGeneration), ]); + if (!isCurrentBackend()) return; - // Check if server is currently syncing - const status = await apiFetch(apiBaseUrl, '/api/status'); if (status.isSyncing) { set({ isLoading: true, loadingPhase: status.phase, loadingMessage: status.message }); let pollDelay = 2000; const schedulePoll = () => { pollInterval = setTimeout(() => { - get().pollStatus().then(() => { - if (get().isLoading) { + get().pollStatus(backendGeneration).then(() => { + const current = get(); + if (current.backendGeneration === backendGeneration && current.isLoading) { pollDelay = Math.min(pollDelay * 1.5, 10000); schedulePoll(); } @@ -525,6 +716,8 @@ export const useChannelStore = create()((set, get schedulePoll(); } set({ + backendConnection: 'connected', + error: null, lastSyncTime: status.lastSyncTime, isCrawling: status.isCrawling, crawlProgress: status.crawlProgress || '', @@ -537,15 +730,17 @@ export const useChannelStore = create()((set, get if (status.isCrawling) { const pollCrawl = () => { pollInterval = setTimeout(async () => { - await get().pollStatus(); - if (get().isCrawling) pollCrawl(); + await get().pollStatus(backendGeneration); + const current = get(); + if (current.backendGeneration === backendGeneration && current.isCrawling) pollCrawl(); }, 5000); }; pollCrawl(); } } catch (err) { + if (!isCurrentBackend()) return; const msg = err instanceof Error ? err.message : 'Cannot connect to server'; - set({ error: msg, _hydrated: true }); + set({ error: msg, backendConnection: 'disconnected', _hydrated: true }); } }, })); diff --git a/src/stores/favoritesStore.ts b/src/stores/favoritesStore.ts index c01fb81..5cd84e3 100644 --- a/src/stores/favoritesStore.ts +++ b/src/stores/favoritesStore.ts @@ -120,3 +120,11 @@ export const useFavoritesStore = create()((se return allChannels.filter(ch => idSet.has(ch.id)); }, })); + +export function resetFavoritesBackendState(): void { + const favoriteIds = new Set(); + const lists: FavoriteList[] = []; + useFavoritesStore.setState({ favoriteIds, lists }); + persistFavorites(favoriteIds); + persistLists(lists); +} diff --git a/src/stores/playerStore.test.ts b/src/stores/playerStore.test.ts new file mode 100644 index 0000000..4f1868d --- /dev/null +++ b/src/stores/playerStore.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useChannelStore } from './channelStore'; +import { usePlayerStore } from './playerStore'; + +describe('player store backend switching', () => { + beforeEach(() => { + vi.restoreAllMocks(); + useChannelStore.setState({ apiBaseUrl: 'http://backend-a.test:3002', backendGeneration: 0 }); + usePlayerStore.setState({ groupChannels: [], groupChannelsLoading: false }); + }); + + it('does not let an old backend failure clear the new backend loading state', async () => { + let rejectOld!: (reason: unknown) => void; + const pendingNew = new Promise(() => undefined); + vi.spyOn(globalThis, 'fetch') + .mockReturnValueOnce(new Promise((_resolve, reject) => { rejectOld = reject; })) + .mockReturnValueOnce(pendingNew); + + const oldRequest = usePlayerStore.getState().fetchGroupChannels('Old'); + await Promise.resolve(); + useChannelStore.setState({ apiBaseUrl: 'http://backend-b.test:3002', backendGeneration: 1 }); + void usePlayerStore.getState().fetchGroupChannels('New'); + await Promise.resolve(); + expect(usePlayerStore.getState().groupChannelsLoading).toBe(true); + + rejectOld(new TypeError('Failed to fetch')); + await oldRequest; + + expect(usePlayerStore.getState().groupChannelsLoading).toBe(true); + }); +}); diff --git a/src/stores/playerStore.ts b/src/stores/playerStore.ts index b48b60d..6a6ec39 100644 --- a/src/stores/playerStore.ts +++ b/src/stores/playerStore.ts @@ -65,15 +65,17 @@ export const usePlayerStore = create()((s }, fetchGroupChannels: async (group: string) => { + const { apiBaseUrl: base, backendGeneration } = useChannelStore.getState(); set({ groupChannelsLoading: true }); try { - const base = useChannelStore.getState().apiBaseUrl; const res = await fetch(`${base}/api/channels?group=${encodeURIComponent(group)}`); if (!res.ok) throw new Error('Failed to fetch group channels'); const data = await res.json(); + if (useChannelStore.getState().backendGeneration !== backendGeneration) return; const channels: Channel[] = data.channels || []; set({ groupChannels: channels, groupChannelsLoading: false }); } catch { + if (useChannelStore.getState().backendGeneration !== backendGeneration) return; set({ groupChannelsLoading: false }); } }, @@ -112,3 +114,15 @@ export const usePlayerStore = create()((s set({ audioOnly }); }, })); + +export function resetPlayerBackendState(): void { + usePlayerStore.setState({ + status: 'idle', + currentChannel: null, + errorMessage: '', + groupChannels: [], + groupChannelsLoading: false, + channelListVisible: true, + audioOnly: false, + }); +} diff --git a/src/stores/recordingStore.test.ts b/src/stores/recordingStore.test.ts index 3ae04ce..1588441 100644 --- a/src/stores/recordingStore.test.ts +++ b/src/stores/recordingStore.test.ts @@ -1,7 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useChannelStore } from './channelStore'; -import { useRecordingStore } from './recordingStore'; +import { resetRecordingBackendState, useRecordingStore } from './recordingStore'; import type { CommercialSegment, CommercialSegmentsResponse } from '../types'; +import { rotateBackendRequestScope } from '../services/api'; function metadata(id: string): CommercialSegmentsResponse { const segment: CommercialSegment = { @@ -30,6 +31,7 @@ function json(value: unknown): Response { describe('recording commercial metadata ordering', () => { beforeEach(() => { vi.restoreAllMocks(); + rotateBackendRequestScope(); useChannelStore.setState({ apiBaseUrl: '' }); useRecordingStore.setState({ recordings: [], @@ -39,6 +41,17 @@ describe('recording commercial metadata ordering', () => { }); }); + it('does not let an old backend rule mutation repopulate the reset store', async () => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => { + resetRecordingBackendState(); + useRecordingStore.setState({ rules: [{ id: 'new-rule' } as never] }); + return json({ rule: { id: 'old-rule' } }); + }); + + await expect(useRecordingStore.getState().createRule({ match_title: 'Old' } as never)).resolves.toBeNull(); + expect(useRecordingStore.getState().rules).toEqual([{ id: 'new-rule' }]); + }); + it('uses the PUT response directly and ignores an older GET that finishes afterward', async () => { let resolveOld!: (response: Response) => void; const oldGet = new Promise((resolve) => { resolveOld = resolve; }); @@ -96,6 +109,7 @@ describe('recording commercial metadata ordering', () => { describe('recording rule mutations', () => { beforeEach(() => { vi.restoreAllMocks(); + rotateBackendRequestScope(); useChannelStore.setState({ apiBaseUrl: '' }); useRecordingStore.setState({ rules: [] }); }); diff --git a/src/stores/recordingStore.ts b/src/stores/recordingStore.ts index cb08469..8e30134 100644 --- a/src/stores/recordingStore.ts +++ b/src/stores/recordingStore.ts @@ -15,6 +15,7 @@ import { apiFetch } from '../services/api'; const toast = (msg: string) => useAppStore.getState().showToastMessage(msg); const commercialRequestVersions = new Map(); const commercialRequestControllers = new Map(); +let recordingBackendVersion = 0; function beginCommercialRequest(id: string, withController: boolean): { version: number; controller?: AbortController } { commercialRequestControllers.get(id)?.abort(); @@ -73,132 +74,167 @@ export const useRecordingStore = create()((se loading: false, fetchRecordings: async (status?: string) => { + const backendVersion = recordingBackendVersion; set({ loading: true }); try { const params = status ? `?status=${encodeURIComponent(status)}` : ''; const data = await apiFetch<{ recordings?: Recording[] }>(getBaseUrl(), `/api/recordings${params}`); + if (backendVersion !== recordingBackendVersion) return; set({ recordings: data.recordings || [] }); } catch (err) { + if (backendVersion !== recordingBackendVersion) return; toast(`Failed to fetch recordings: ${err}`); } finally { - set({ loading: false }); + if (backendVersion === recordingBackendVersion) set({ loading: false }); } }, fetchRules: async () => { + const backendVersion = recordingBackendVersion; try { const data = await apiFetch<{ rules?: RecordingRule[] }>(getBaseUrl(), '/api/recording-rules'); + if (backendVersion !== recordingBackendVersion) return; set({ rules: data.rules || [] }); } catch (err) { + if (backendVersion !== recordingBackendVersion) return; toast(`Failed to fetch rules: ${err}`); } }, fetchStatus: async () => { + const backendVersion = recordingBackendVersion; try { const data = await apiFetch(getBaseUrl(), '/api/recording-status'); + if (backendVersion !== recordingBackendVersion) return; set({ status: data }); } catch (err) { + if (backendVersion !== recordingBackendVersion) return; toast(`Failed to fetch recording status: ${err}`); } }, createRecording: async (channelId, title, startTime, endTime) => { + const backendVersion = recordingBackendVersion; try { const data = await apiFetch<{ recording: Recording }>(getBaseUrl(), '/api/recordings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channelId, title, startTime, endTime }), }); + if (backendVersion !== recordingBackendVersion) return null; await get().fetchRecordings(); + if (backendVersion !== recordingBackendVersion) return null; return data.recording; } catch (err) { + if (backendVersion !== recordingBackendVersion) return null; toast(`Failed to create recording: ${err}`); return null; } }, createFromProgram: async (channelId, programStart, programStop, title) => { + const backendVersion = recordingBackendVersion; try { const data = await apiFetch<{ recording: Recording }>(getBaseUrl(), '/api/recordings/from-program', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channelId, programStart, programStop, title }), }); + if (backendVersion !== recordingBackendVersion) return null; await get().fetchRecordings(); + if (backendVersion !== recordingBackendVersion) return null; return data.recording; } catch (err) { + if (backendVersion !== recordingBackendVersion) return null; toast(`Failed to create recording: ${err}`); return null; } }, cancelRecording: async (id) => { + const backendVersion = recordingBackendVersion; try { await apiFetch(getBaseUrl(), `/api/recordings/${encodeURIComponent(id)}/cancel`, { method: 'POST' }); + if (backendVersion !== recordingBackendVersion) return; await get().fetchRecordings(); } catch (err) { + if (backendVersion !== recordingBackendVersion) return; toast(`Failed to cancel recording: ${err}`); } }, stopRecording: async (id) => { + const backendVersion = recordingBackendVersion; try { await apiFetch(getBaseUrl(), `/api/recordings/${encodeURIComponent(id)}/stop`, { method: 'POST' }); + if (backendVersion !== recordingBackendVersion) return; await get().fetchRecordings(); } catch (err) { + if (backendVersion !== recordingBackendVersion) return; toast(`Failed to stop recording: ${err}`); } }, deleteRecording: async (id) => { + const backendVersion = recordingBackendVersion; try { await apiFetch(getBaseUrl(), `/api/recordings/${encodeURIComponent(id)}`, { method: 'DELETE' }); + if (backendVersion !== recordingBackendVersion) return; set((state) => { const commercialSegments = { ...state.commercialSegments }; delete commercialSegments[id]; return { recordings: state.recordings.filter((recording) => recording.id !== id), commercialSegments }; }); } catch (err) { + if (backendVersion !== recordingBackendVersion) return; toast(`Failed to delete recording: ${err}`); } }, createRule: async (input) => { + const backendVersion = recordingBackendVersion; try { const data = await apiFetch<{ rule: RecordingRule }>(getBaseUrl(), '/api/recording-rules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); + if (backendVersion !== recordingBackendVersion) return null; set((state) => ({ rules: [...state.rules.filter((rule) => rule.id !== data.rule.id), data.rule] })); return data.rule; } catch (err) { + if (backendVersion !== recordingBackendVersion) return null; toast(`Failed to create rule: ${err}`); return null; } }, updateRule: async (id, updates) => { + const backendVersion = recordingBackendVersion; try { const data = await apiFetch<{ rule: RecordingRule }>(getBaseUrl(), `/api/recording-rules/${encodeURIComponent(id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(updates), }); + if (backendVersion !== recordingBackendVersion) return; set((state) => ({ rules: state.rules.map((rule) => rule.id === id ? data.rule : rule), })); } catch (err) { + if (backendVersion !== recordingBackendVersion) return; toast(`Failed to update rule: ${err}`); } }, deleteRule: async (id) => { + const backendVersion = recordingBackendVersion; try { await apiFetch(getBaseUrl(), `/api/recording-rules/${encodeURIComponent(id)}`, { method: 'DELETE' }); + if (backendVersion !== recordingBackendVersion) return; set({ rules: get().rules.filter((rule) => rule.id !== id) }); } catch (err) { + if (backendVersion !== recordingBackendVersion) return; toast(`Failed to delete rule: ${err}`); } }, @@ -238,8 +274,10 @@ export const useRecordingStore = create()((se }, analyzeCommercials: async (id) => { + const backendVersion = recordingBackendVersion; try { await apiFetch(getBaseUrl(), `/api/recordings/${encodeURIComponent(id)}/analyze`, { method: 'POST' }); + if (backendVersion !== recordingBackendVersion) return false; set((state) => ({ recordings: state.recordings.map((recording) => recording.id === id ? { ...recording, analysis_state: 'queued', analysis_error: null } @@ -247,12 +285,14 @@ export const useRecordingStore = create()((se })); return true; } catch (err) { + if (backendVersion !== recordingBackendVersion) return false; toast(`Failed to start commercial analysis: ${err}`); return false; } }, saveCommercialSegments: async (id, segments) => { + const backendVersion = recordingBackendVersion; const { version } = beginCommercialRequest(id, false); try { const updated = await apiFetch(getBaseUrl(), `/api/recordings/${encodeURIComponent(id)}/commercial-segments`, { @@ -260,21 +300,24 @@ export const useRecordingStore = create()((se headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ segments }), }); - if (!isCurrentCommercialRequest(id, version)) return null; + if (backendVersion !== recordingBackendVersion || !isCurrentCommercialRequest(id, version)) return null; set((state) => ({ commercialSegments: { ...state.commercialSegments, [id]: updated }, commercialSegmentsLoading: { ...state.commercialSegmentsLoading, [id]: false }, commercialSegmentsError: { ...state.commercialSegmentsError, [id]: null }, })); await get().fetchRecordings(); + if (backendVersion !== recordingBackendVersion) return null; return updated; } catch (err) { + if (backendVersion !== recordingBackendVersion || !isCurrentCommercialRequest(id, version)) return null; toast(`Failed to save commercial intervals: ${err}`); return null; } }, setCommercialSkipOverride: async (id, enabled) => { + const backendVersion = recordingBackendVersion; const { version } = beginCommercialRequest(id, false); try { const updated = await apiFetch(getBaseUrl(), `/api/recordings/${encodeURIComponent(id)}/commercial-skip`, { @@ -282,7 +325,7 @@ export const useRecordingStore = create()((se headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }), }); - if (!isCurrentCommercialRequest(id, version)) return null; + if (backendVersion !== recordingBackendVersion || !isCurrentCommercialRequest(id, version)) return null; set((state) => ({ commercialSegments: { ...state.commercialSegments, [id]: updated }, commercialSegmentsLoading: { ...state.commercialSegmentsLoading, [id]: false }, @@ -293,8 +336,25 @@ export const useRecordingStore = create()((se })); return updated; } catch (err) { + if (backendVersion !== recordingBackendVersion || !isCurrentCommercialRequest(id, version)) return null; toast(`Failed to update commercial skipping: ${err}`); return null; } }, })); + +export function resetRecordingBackendState(): void { + recordingBackendVersion++; + for (const controller of commercialRequestControllers.values()) controller.abort(); + commercialRequestControllers.clear(); + commercialRequestVersions.clear(); + useRecordingStore.setState({ + recordings: [], + rules: [], + status: null, + commercialSegments: {}, + commercialSegmentsLoading: {}, + commercialSegmentsError: {}, + loading: false, + }); +} diff --git a/src/utils/__tests__/pwa-cache.test.ts b/src/utils/__tests__/pwa-cache.test.ts index e8e5fdd..d6f19b8 100644 --- a/src/utils/__tests__/pwa-cache.test.ts +++ b/src/utils/__tests__/pwa-cache.test.ts @@ -3,8 +3,11 @@ import { CACHEABLE_API_PATTERN } from '../pwa-cache'; describe('CACHEABLE_API_PATTERN', () => { it('excludes transient media delivery endpoints from the service-worker cache', () => { - expect(CACHEABLE_API_PATTERN.test('/api/config')).toBe(true); + expect(CACHEABLE_API_PATTERN.test('/api/channels?group=News')).toBe(true); for (const path of [ + '/api/status', + '/api/config', + '/api/programs?from=1&to=2', '/api/stream/vod_1', '/api/proxy/example', '/api/remux/vod_1', @@ -12,6 +15,9 @@ describe('CACHEABLE_API_PATTERN', () => { '/api/subtitles/episode_1?url=http%3A%2F%2Fprovider.example%2Fepisode.mkv', '/api/subtitles/episode_1/3.vtt?url=http%3A%2F%2Fprovider.example%2Fepisode.mkv', '/api/recordings/recording_1', + '/api/recording-rules', + '/api/recording-rules/rule_1', + '/api/recording-status', '/api/recordings/recording_1/commercial-segments', '/api/ios-hls/vod_1/index.m3u8', '/api/ios-hls-authorize/vod_1/index.m3u8', diff --git a/src/utils/early-log.test.ts b/src/utils/early-log.test.ts new file mode 100644 index 0000000..8d6bd83 --- /dev/null +++ b/src/utils/early-log.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +describe('early logger backend URL', () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + localStorage.clear(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('parses the JSON-encoded backend URL written by the storage helper', async () => { + localStorage.setItem('streamvault_api_url', JSON.stringify('http://192.168.1.20:3002')); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 202 })); + const { earlyLog } = await import('./early-log'); + + earlyLog('boot'); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://192.168.1.20:3002/api/client-logs', + expect.objectContaining({ method: 'POST' }), + ); + }); + + it('still supports backend URLs stored by older releases as raw strings', async () => { + localStorage.setItem('streamvault_api_url', 'http://192.168.1.21:3002'); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 202 })); + const { earlyLog } = await import('./early-log'); + + earlyLog('boot'); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://192.168.1.21:3002/api/client-logs', + expect.objectContaining({ method: 'POST' }), + ); + }); +}); diff --git a/src/utils/early-log.ts b/src/utils/early-log.ts index c61e692..abe0441 100644 --- a/src/utils/early-log.ts +++ b/src/utils/early-log.ts @@ -7,9 +7,25 @@ declare const __SERVER_URL__: string; +function readStoredServerUrl(): string { + if (typeof localStorage === 'undefined') return ''; + try { + const raw = localStorage.getItem('streamvault_api_url'); + if (!raw) return ''; + try { + const parsed: unknown = JSON.parse(raw); + return typeof parsed === 'string' ? parsed : ''; + } catch { + return raw; + } + } catch { + return ''; + } +} + const SERVER_URL: string = (typeof __SERVER_URL__ !== 'undefined' && __SERVER_URL__) || - (typeof localStorage !== 'undefined' && localStorage.getItem('streamvault_api_url')) || + readStoredServerUrl() || ''; function send(level: 'debug' | 'info' | 'warn' | 'error', message: string): void { diff --git a/src/utils/pwa-cache.ts b/src/utils/pwa-cache.ts index e4c4037..c843887 100644 --- a/src/utils/pwa-cache.ts +++ b/src/utils/pwa-cache.ts @@ -1,2 +1,2 @@ /** API responses that are safe for the PWA runtime cache. */ -export const CACHEABLE_API_PATTERN = /\/api\/(?!(?:stream|proxy|remux|transcode|subtitles|recordings|ios-hls|ios-hls-authorize|ios-hls-assets)(?:\/|$))/; +export const CACHEABLE_API_PATTERN = /\/api\/(?!(?:status|config|programs|stream|proxy|remux|transcode|subtitles|recordings|recording-rules|recording-status|ios-hls|ios-hls-authorize|ios-hls-assets)(?:\/|\?|$))/; diff --git a/src/utils/server-connection.test.ts b/src/utils/server-connection.test.ts new file mode 100644 index 0000000..fbd945c --- /dev/null +++ b/src/utils/server-connection.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { resolveBuildServerUrl, usesSameOriginApi } from './server-connection'; + +describe('resolveBuildServerUrl', () => { + it('leaves public builds unbound when no server is configured', () => { + expect(resolveBuildServerUrl({})).toBe(''); + }); + + it('uses an explicitly configured URL or developer LAN IP', () => { + expect(resolveBuildServerUrl({ VITE_SERVER_URL: 'https://streamvault.example' })) + .toBe('https://streamvault.example'); + expect(resolveBuildServerUrl({ VITE_SERVER_URL: '' })).toBe(''); + expect(resolveBuildServerUrl({ VITE_SERVER_IP: '192.168.1.20' })) + .toBe('http://192.168.1.20:3002'); + }); +}); + +describe('usesSameOriginApi', () => { + it('uses relative API paths only for an HTTP(S) app with no configured server', () => { + expect(usesSameOriginApi('', 'http:')).toBe(true); + expect(usesSameOriginApi('', 'https:')).toBe(true); + expect(usesSameOriginApi('', 'file:')).toBe(false); + expect(usesSameOriginApi('', 'widget:')).toBe(false); + expect(usesSameOriginApi('http://192.168.1.20:3002', 'http:')).toBe(false); + }); +}); diff --git a/src/utils/server-connection.ts b/src/utils/server-connection.ts new file mode 100644 index 0000000..1ae6881 --- /dev/null +++ b/src/utils/server-connection.ts @@ -0,0 +1,12 @@ +type ServerBuildEnvironment = Readonly>; + +/** Resolve the API URL embedded in a frontend build without leaking build-host addresses. */ +export function resolveBuildServerUrl(env: ServerBuildEnvironment): string { + if (env.VITE_SERVER_URL !== undefined) return env.VITE_SERVER_URL; + return env.VITE_SERVER_IP ? `http://${env.VITE_SERVER_IP}:3002` : ''; +} + +/** Relative API paths are valid for a hosted PWA, but not a local Tizen widget. */ +export function usesSameOriginApi(defaultServerUrl: string, protocol: string): boolean { + return defaultServerUrl.length === 0 && (protocol === 'http:' || protocol === 'https:'); +} diff --git a/vite.config.ts b/vite.config.ts index c11d2a8..f8918ea 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,4 @@ import { defineConfig, type Plugin } from 'vite' -import { execSync } from 'child_process' import { readFileSync, writeFileSync } from 'fs' import { resolve } from 'path' import { fileURLToPath } from 'url' @@ -11,12 +10,12 @@ import postcss from 'postcss' import postcssPresetEnv from 'postcss-preset-env' import type { Plugin as PostcssPlugin, Declaration as PostcssDeclaration } from 'postcss' import { CACHEABLE_API_PATTERN } from './src/utils/pwa-cache.ts' +import { resolveBuildServerUrl } from './src/utils/server-connection.ts' -// When VITE_SERVER_URL is explicitly set (e.g. "" for Docker/PWA), use it. -// Otherwise detect LAN IP for Tizen TV dev builds. -const serverUrl = process.env.VITE_SERVER_URL !== undefined - ? process.env.VITE_SERVER_URL - : `http://${process.env.VITE_SERVER_IP || execSync('hostname -I').toString().trim().split(/\s+/)[0]}:3002`; +// Public builds must not inherit the build machine's private LAN address. +// Developers can still opt into a preconfigured TV build with VITE_SERVER_IP +// or provide a complete URL with VITE_SERVER_URL. +const serverUrl = resolveBuildServerUrl(process.env); /** * Lower modern CSS to syntax old Samsung Tizen browsers understand.