Merge remote-tracking branch 'origin/master' into claude/fervent-cannon-196cb2

This commit is contained in:
4gray committed 2026-10-12 08:07:47 +02:00
commit c2f8b9f7af
99 files changed
+13884 -875

No files matched your search

@@ -0,0 +1,6 @@
---
type: perf
area: database
---
The second launch after setting up a new library no longer rebuilds the whole title search index before the app opens, which took several seconds on large catalogs (about 4.5 s per million titles).
@@ -0,0 +1,9 @@
---
type: fix
area: playback
---
With "Reuse player instance" on, IPTVnator now waits for MPV to confirm each
command sent to the running player. A command MPV rejects or never answers
falls back to a fresh MPV launch instead of reporting the stream as opened
while nothing changed.
@@ -0,0 +1,7 @@
---
type: feature
area: self-hosted
issues: [305]
---
The Docker image now keeps a server database on a `/data` volume, the groundwork for sharing playlists and watch progress across devices. Mount a volume to keep it; nothing changes in the browser yet.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+52
View File
@@ -0,0 +1,52 @@
# Server Storage for the Self-hosted PWA — Overview
Spec: `docs/superpowers/specs/2026-10-08-server-storage-design.md`.
Branch base: `origin/master` at `7abf479de` (2026-10-11).
## Order of work
Three plans, three pull requests, merged in this order. Each one leaves the
app working and fully tested.
| Plan | PR title | Release note |
| --- | --- | --- |
| [Phase 1: seams](2026-10-11-server-storage-phase-1-seams.md) | `refactor(storage): seams for server-backed playlist storage` | none, label `no-release-note` (behavior-preserving) |
| [Phase 2a: backend](2026-10-11-server-storage-phase-2a-backend.md) | `feat(self-hosted): server storage API in web-backend` | none yet, label `no-release-note`; the PWA does not use it until 2b |
| [Phase 2b: PWA](2026-10-11-server-storage-phase-2b-pwa.md) | `feat(self-hosted): keep playlists on the server for every device` | `.changes/self-hosted-server-storage.md`, closes #305 |
Phase 2a only depends on Phase 1 Tasks 2 and 3 (shared codec, data-dir
variable). Phase 2b depends on all of Phase 1 and 2a.
## Branches and worktrees
- One worktree per PR under `.claude/worktrees/`, each with its own
`pnpm install --frozen-lockfile` (AGENTS.md).
- Phase 1 branch: `claude/server-storage` (this worktree, already holds the
spec and the plans).
- Phase 2a branch: `claude/server-storage-backend`, created from
`claude/server-storage` after Phase 1 is committed, rebased onto `master`
once Phase 1 merges.
- Phase 2b branch: `claude/server-storage-pwa`, created from the 2a branch the
same way.
## Gates for every PR (from AGENTS.md)
1. Targeted unit tests and lint of each touched project.
2. `pnpm run typecheck:ci`.
3. Docs updated in the canonical owner (listed per plan).
4. Local review gate: commit, then `codex review --base origin/master -c model_reasoning_effort=high`
and `greptile review --json` on the same commit, fix, repeat until both are clean (max five rounds).
5. Push once, open the PR, report checks, skipped checks with reasons, docs and release-note status.
## Decisions to keep in mind while implementing
- Do not change `PlaylistsService`'s public API. All storage selection goes
through `PlaylistStorageBackend`.
- Never add files to `tools/eslint/max-lines-baseline.mjs`. New production
files stay under 300 lines.
- Storage errors on the server are logged by error name and route only;
Drizzle messages can carry SQL parameters (portal credentials).
- The server-mode browser cache uses the key prefix `iptvnator-server:`;
never write server data into the legacy `xtream-*` keys.
- Translate every new i18n key in all 19 locales; `pnpm run i18n:check`
must pass.
@@ -33,6 +33,10 @@ import {
type ElectronExitConfirmationOptions,
prepareElectronApplication,
} from './electron-process-lifecycle';
import {
waitForXtreamImportCompleted,
xtreamPlaylistIdFromUrl,
} from './xtream-import-wait';
export const workspaceRoot = resolve(__dirname, '../../..');
export const electronMainPath = join(
@@ -1769,7 +1773,13 @@ export async function waitForXtreamImportToFinish(page: Page): Promise<void> {
try {
await overlay.waitFor({ state: 'visible', timeout: 5000 });
} catch {
// The overlay may already be gone by the time the test reaches this point.
// The overlay may already be gone, or not shown yet: it appears only
// once the portal has answered, so the import status decides below.
}
const playlistId = xtreamPlaylistIdFromUrl(page.url());
if (playlistId) {
await waitForXtreamImportCompleted(page, playlistId);
}
await expect(overlay).toHaveCount(0, { timeout: 30000 });
@@ -0,0 +1,174 @@
import { createServer, request as forwardRequest } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { Page } from '@playwright/test';
import {
addXtreamPortal,
closeElectronApp,
expect,
launchElectronApp,
openSources,
refreshSource,
resetMockServers,
test,
waitForXtreamCatalog,
xtreamMockServer,
} from './electron-test-fixtures';
import {
readXtreamImportState,
xtreamPlaylistIdFromUrl,
} from './xtream-import-wait';
/**
* Longer than the 5 s the catalog wait gives the loading overlay to appear.
* The overlay appears only once the account check has answered, which a mock
* generating a large catalog on its first request can take this long to do.
*/
const ACCOUNT_INFO_DELAY_MS = 7_000;
/** Keeps series importing after the movie cards are already visible. */
const SERIES_LIST_DELAY_MS = 3_000;
interface DelayingProxy {
readonly origin: string;
/** Holds the account check and the series list from now on. */
slowDown(): void;
close(): Promise<void>;
}
function portalDelay(url: URL): number {
if (!url.pathname.endsWith('/player_api.php')) {
return 0;
}
const action = url.searchParams.get('action');
if (action === null || action === 'get_account_info') {
return ACCOUNT_INFO_DELAY_MS;
}
return action === 'get_series' ? SERIES_LIST_DELAY_MS : 0;
}
/** Forwards GET requests to `target`, delayed by `portalDelay` once slowed. */
async function startDelayingProxy(
target: string,
slow: boolean
): Promise<DelayingProxy> {
const upstream = new URL(target);
let delaying = slow;
const server = createServer((request, response) => {
const url = new URL(request.url ?? '/', 'http://proxy.invalid');
setTimeout(
() => {
const forwarded = forwardRequest(
{
headers: { ...request.headers, host: upstream.host },
hostname: upstream.hostname,
method: request.method,
path: request.url,
port: upstream.port,
},
(answer) => {
response.writeHead(
answer.statusCode ?? 502,
answer.headers
);
answer.pipe(response);
}
);
forwarded.on('error', () => response.destroy());
forwarded.end();
},
delaying ? portalDelay(url) : 0
);
});
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve)
);
const { port } = server.address() as AddressInfo;
return {
origin: `http://127.0.0.1:${port}`,
slowDown: () => {
delaying = true;
},
close: () =>
new Promise<void>((resolve) => {
server.closeAllConnections();
server.close(() => resolve());
}),
};
}
/**
* Closing the app right after the wait must not interrupt the import: the next
* launch reuses a type only when its status reads `completed` and rows exist,
* and global search finds nothing of a type whose rows are missing.
*/
async function expectCatalogComplete(page: Page): Promise<void> {
const playlistId = xtreamPlaylistIdFromUrl(page.url());
expect(playlistId).not.toBeNull();
const imported = {
status: 'completed',
hasCategories: true,
hasContent: true,
};
expect(await readXtreamImportState(page, playlistId as string)).toEqual({
live: imported,
movie: imported,
series: imported,
});
}
test.describe('Electron Xtream catalog wait', () => {
test('@xtream @electron waits for every type when the portal answers after the overlay window', async ({
dataDir,
request,
}) => {
test.setTimeout(120_000);
await resetMockServers(request, ['xtream']);
const proxy = await startDelayingProxy(xtreamMockServer, true);
try {
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: 'Slow account check portal',
serverUrl: proxy.origin,
});
await waitForXtreamCatalog(app.mainWindow);
await expectCatalogComplete(app.mainWindow);
} finally {
await closeElectronApp(app);
}
} finally {
await proxy.close();
}
});
test('@xtream @electron waits for the re-import after a refresh, whose statuses still read completed', async ({
dataDir,
request,
}) => {
test.setTimeout(150_000);
await resetMockServers(request, ['xtream']);
const proxy = await startDelayingProxy(xtreamMockServer, false);
try {
const app = await launchElectronApp(dataDir);
try {
const title = 'Slow refresh portal';
await addXtreamPortal(app.mainWindow, {
name: title,
serverUrl: proxy.origin,
});
await waitForXtreamCatalog(app.mainWindow);
// A refresh deletes the catalog and re-imports it, leaving
// the three statuses at `completed` from the first import.
proxy.slowDown();
await openSources(app.mainWindow);
await refreshSource(app.mainWindow, title, { confirm: true });
await waitForXtreamCatalog(app.mainWindow);
await expectCatalogComplete(app.mainWindow);
} finally {
await closeElectronApp(app);
}
} finally {
await proxy.close();
}
});
});
@@ -0,0 +1,117 @@
import type { Page } from '@playwright/test';
/** Content types an Xtream import persists, in the app's status keys. */
export const XTREAM_IMPORT_TYPES = ['live', 'movie', 'series'] as const;
export type XtreamImportType = (typeof XTREAM_IMPORT_TYPES)[number];
/** What the database holds for one content type of a portal. */
export interface XtreamImportTypeState {
/** `xtream-import-status:<playlistId>:<type>` in `app_state`. */
readonly status: string | null;
readonly hasCategories: boolean;
readonly hasContent: boolean;
}
export type XtreamImportState = Record<XtreamImportType, XtreamImportTypeState>;
const XTREAM_ROUTE_PLAYLIST_ID = /\/workspace\/xtreams\/([^/?#]+)/;
/** Category types use the plural `movies`; content and status use `movie`. */
const CATEGORY_TYPE: Record<XtreamImportType, string> = {
live: 'live',
movie: 'movies',
series: 'series',
};
const POLL_MS = 100;
const DEFAULT_TIMEOUT_MS = 60_000;
/** The portal id of an `/workspace/xtreams/<id>/…` URL, or null. */
export function xtreamPlaylistIdFromUrl(url: string): string | null {
const match = XTREAM_ROUTE_PLAYLIST_ID.exec(url);
return match ? decodeURIComponent(match[1]) : null;
}
/** Status, category and content presence of every type, read through the bridge. */
export async function readXtreamImportState(
page: Page,
playlistId: string
): Promise<XtreamImportState> {
return page.evaluate(
async ({ id, types, categoryType }) => {
const entries = await Promise.all(
types.map(async (type) => [
type,
{
status: await window.electron.dbGetAppState(
`xtream-import-status:${id}:${type}`
),
hasCategories: await window.electron.dbHasCategories(
id,
categoryType[type]
),
hasContent: await window.electron.dbHasContent(
id,
type
),
},
])
);
return Object.fromEntries(entries);
},
{
id: playlistId,
types: [...XTREAM_IMPORT_TYPES],
categoryType: CATEGORY_TYPE,
}
) as Promise<XtreamImportState>;
}
/**
* Whether the portal's catalog is complete, judged the way the app judges it
* on its next start: it reuses a type only when the status reads `completed`
* and rows exist. A type without categories is empty on the portal (a
* live-only panel), so its status alone counts; but a portal without any
* category has not been imported yet, or a refresh has just deleted it. The
* status alone is not enough either: a refresh deletes the catalog and
* re-imports it without resetting the status.
*/
export function isXtreamImportComplete(state: XtreamImportState): boolean {
const types = XTREAM_IMPORT_TYPES.map((type) => state[type]);
return (
types.some(({ hasCategories }) => hasCategories) &&
types.every(
({ status, hasCategories, hasContent }) =>
status === 'completed' && (hasContent || !hasCategories)
)
);
}
/**
* Waits until the portal's catalog is complete (`isXtreamImportComplete`).
*
* The loading overlay alone cannot prove that: it appears only once the
* portal has answered the account check and the import session starts. When
* that answer takes longer than the caller waits for the overlay (a mock
* generating a large catalog on its first request does), a check for "no
* overlay" passes before anything was imported, and closing the app then
* interrupts the import. A `cancelled` or `failed` status does not end the
* wait: the app writes no status when a retry starts, so it may be left over
* from an earlier attempt that is being retried now.
*/
export async function waitForXtreamImportCompleted(
page: Page,
playlistId: string,
timeoutMs = DEFAULT_TIMEOUT_MS
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let state = await readXtreamImportState(page, playlistId);
while (!isXtreamImportComplete(state)) {
if (Date.now() >= deadline) {
throw new Error(
`xtream-import-not-completed: ${JSON.stringify(state)}`
);
}
await page.waitForTimeout(POLL_MS);
state = await readXtreamImportState(page, playlistId);
}
}
+4
View File
@@ -103,6 +103,10 @@ async function buildWorker() {
__dirname,
'../../libs/shared/database/src/lib/path-utils.ts'
),
'@iptvnator/shared/database/app-playlist': path.join(
__dirname,
'../../libs/shared/database/src/lib/app-playlist.ts'
),
},
});
}
@@ -17,16 +17,18 @@ import { sql, type SQL } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
export {
playlistConflictUpdate,
readPayloadServerTimezone,
type PlaylistConflictUpdate,
} from '@iptvnator/shared/database/app-playlist';
export interface PlaylistConnectionIdentity {
serverUrl: string;
username: string;
password: string;
}
export type PlaylistConflictUpdate = {
[K in keyof schema.NewPlaylist]?: schema.NewPlaylist[K] | SQL;
};
const stored = schema.playlists;
/**
@@ -86,70 +88,3 @@ export function serverTimezoneInvalidation(nextServerUrl: string): SQL {
ELSE ${stored.payload}
END`;
}
/**
* The `DO UPDATE` half of an app-playlist upsert. A full upsert is built
* from a snapshot the caller read earlier; when that snapshot carries no
* panel clock while the stored row does — `setPlaylistServerTimezone`
* landed in between — the stored clock is carried over as long as the row
* still points at the same panel, so a favorites, recent-items or metadata
* write cannot hand a clockless payload back to the past. A snapshot that
* carries its own clock, or moves the source, wins as is. Nested CASE, not
* AND: SQLite may reorder AND terms, and the json_* readers raise on a
* malformed payload unless json_valid ran first.
*/
export function playlistConflictUpdate(
row: schema.NewPlaylist,
playlist: Record<string, unknown>
): PlaylistConflictUpdate {
if (readString(playlist.serverTimezone)) {
return row;
}
const excludedPayload = sql.raw(`excluded.${stored.payload.name}`);
const payload = sql`CASE
WHEN json_valid(${stored.payload})
THEN CASE
WHEN json_type(${stored.payload}, '$.serverTimezone') = 'text'
AND ${stored.serverUrl} IS excluded.${sql.raw(stored.serverUrl.name)}
AND ${stored.username} IS excluded.${sql.raw(stored.username.name)}
AND ${stored.password} IS excluded.${sql.raw(stored.password.name)}
THEN json_set(
${excludedPayload},
'$.serverTimezone',
json_extract(${stored.payload}, '$.serverTimezone')
)
ELSE ${excludedPayload}
END
ELSE ${excludedPayload}
END`;
return { ...row, payload };
}
/**
* The clock stored in a raw row's payload, for `DB_GET_PLAYLIST`: the
* Xtream store seeds `currentPlaylist` from that read before (or without)
* the account-info check that learns the value.
*/
export function readPayloadServerTimezone(
payload: string | null | undefined
): string | undefined {
if (!payload) {
return undefined;
}
try {
const parsed: unknown = JSON.parse(payload);
return parsed && typeof parsed === 'object'
? readString((parsed as Record<string, unknown>).serverTimezone)
: undefined;
} catch {
return undefined;
}
}
function readString(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
}
@@ -28,14 +28,20 @@ import {
readPayloadServerTimezone,
serverTimezoneInvalidation,
} from './playlist-server-timezone.operations';
import {
buildPlaylistRow,
getPlaylistItemCount,
parseAppPlaylist,
parseJsonValue,
PLAYLIST_TYPES,
type AppPlaylistType,
} from '@iptvnator/shared/database/app-playlist';
const PLAYLIST_TYPES = {
XTREAM: 'xtream',
STALKER: 'stalker',
M3U_FILE: 'm3u-file',
M3U_TEXT: 'm3u-text',
M3U_URL: 'm3u-url',
} as const;
// Kept for existing importers (specs, playlist-migration.operations.ts).
export {
buildPlaylistRow,
parseAppPlaylist,
} from '@iptvnator/shared/database/app-playlist';
export interface AppPlaylistUpsertPhaseCapture {
captureAsync: <TResult>(
@@ -63,56 +69,6 @@ export interface AppPlaylistGetPhaseCapture {
) => TResult;
}
type PlaylistType = (typeof PLAYLIST_TYPES)[keyof typeof PLAYLIST_TYPES];
const PLAYLIST_TYPE_VALUES = new Set<PlaylistType>(
Object.values(PLAYLIST_TYPES)
);
function getStringValue(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
}
function getNumericValue(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
function parseJsonValue<T>(value: string | null | undefined, fallback: T): T {
if (!value) {
return fallback;
}
try {
return JSON.parse(value) as T;
} catch (error) {
console.warn('Failed to parse JSON value from DB:', error);
return fallback;
}
}
function getStringArrayValue(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item) => item.length > 0);
}
function getFavoriteChannelMatch(
channel: Channel,
favoritePositions: ReadonlyMap<string, number>
@@ -143,191 +99,6 @@ function getFavoriteChannelMatch(
return null;
}
function inferPlaylistType(playlist: Record<string, unknown>): PlaylistType {
const explicitType = getStringValue(playlist.type);
if (
explicitType &&
PLAYLIST_TYPE_VALUES.has(explicitType as PlaylistType)
) {
return explicitType as PlaylistType;
}
if (getStringValue(playlist.serverUrl)) {
return PLAYLIST_TYPES.XTREAM;
}
if (getStringValue(playlist.macAddress)) {
return PLAYLIST_TYPES.STALKER;
}
if (getStringValue(playlist.filePath)) {
return PLAYLIST_TYPES.M3U_FILE;
}
if (getStringValue(playlist.url)) {
return PLAYLIST_TYPES.M3U_URL;
}
return PLAYLIST_TYPES.M3U_TEXT;
}
export function buildPlaylistRow(
playlist: Record<string, unknown>
): schema.NewPlaylist | null {
const id = getStringValue(playlist._id) ?? getStringValue(playlist.id);
if (!id) {
return null;
}
const type = inferPlaylistType(playlist);
const portalUrl = getStringValue(playlist.portalUrl);
const url = getStringValue(playlist.url);
const nowIso = new Date().toISOString();
const updateDate = getNumericValue(playlist.updateDate);
return {
id,
name:
getStringValue(playlist.title) ??
getStringValue(playlist.name) ??
id,
serverUrl: getStringValue(playlist.serverUrl),
username: getStringValue(playlist.username),
password: getStringValue(playlist.password),
lastUpdated:
typeof updateDate === 'number'
? new Date(updateDate).toISOString()
: getStringValue(playlist.lastUpdated),
type,
userAgent: getStringValue(playlist.userAgent),
origin: getStringValue(playlist.origin),
referrer: getStringValue(playlist.referrer),
filePath: getStringValue(playlist.filePath),
epgUrls:
playlist.epgUrls !== undefined
? JSON.stringify(getStringArrayValue(playlist.epgUrls))
: undefined,
detectedEpgUrls:
playlist.detectedEpgUrls !== undefined
? JSON.stringify(getStringArrayValue(playlist.detectedEpgUrls))
: undefined,
manualEpgUrls:
playlist.manualEpgUrls !== undefined
? JSON.stringify(getStringArrayValue(playlist.manualEpgUrls))
: undefined,
disabledEpgUrls:
playlist.disabledEpgUrls !== undefined
? JSON.stringify(getStringArrayValue(playlist.disabledEpgUrls))
: undefined,
autoRefresh: Boolean(playlist.autoRefresh),
macAddress: getStringValue(playlist.macAddress),
url: type === PLAYLIST_TYPES.STALKER ? (portalUrl ?? url) : url,
portalUrl,
count: getNumericValue(playlist.count),
importDate: getStringValue(playlist.importDate),
updateDate,
position: getNumericValue(playlist.position),
favorites:
playlist.favorites !== undefined
? JSON.stringify(playlist.favorites)
: undefined,
recentlyViewed:
playlist.recentlyViewed !== undefined
? JSON.stringify(playlist.recentlyViewed)
: undefined,
payload: JSON.stringify(playlist),
lastUsage: getStringValue(playlist.lastUsage) ?? nowIso,
};
}
function getPlaylistItemCount(
playlist: Record<string, unknown>
): number | undefined {
try {
const value = playlist.playlist;
if (typeof value !== 'object' || value === null) {
return undefined;
}
const items = Reflect.get(value, 'items');
return Array.isArray(items) ? items.length : undefined;
} catch {
return undefined;
}
}
export function parseAppPlaylist(
row: schema.Playlist
): Record<string, unknown> {
const payload = parseJsonValue<Record<string, unknown> | null>(
row.payload,
null
);
const base = payload && typeof payload === 'object' ? payload : {};
const favorites = parseJsonValue<unknown[]>(row.favorites, []);
const recentlyViewed = parseJsonValue<unknown[]>(row.recentlyViewed, []);
const epgUrls = getStringArrayValue(
row.epgUrls ? parseJsonValue<unknown[]>(row.epgUrls, []) : base.epgUrls
);
const detectedEpgUrls = getStringArrayValue(
row.detectedEpgUrls
? parseJsonValue<unknown[]>(row.detectedEpgUrls, [])
: (base.detectedEpgUrls ?? epgUrls)
);
const manualEpgUrls = getStringArrayValue(
row.manualEpgUrls
? parseJsonValue<unknown[]>(row.manualEpgUrls, [])
: base.manualEpgUrls
);
const disabledEpgUrls = getStringArrayValue(
row.disabledEpgUrls
? parseJsonValue<unknown[]>(row.disabledEpgUrls, [])
: base.disabledEpgUrls
);
const importDate =
row.importDate ?? row.dateCreated ?? new Date().toISOString();
const portalUrl =
row.portalUrl ?? (row.type === PLAYLIST_TYPES.STALKER ? row.url : null);
const updateDate =
row.updateDate ??
(row.lastUpdated ? new Date(row.lastUpdated).getTime() : undefined);
return {
...base,
_id: row.id,
title:
getStringValue(base.title) ?? getStringValue(base.name) ?? row.name,
count: row.count ?? getNumericValue(base.count) ?? 0,
importDate: getStringValue(base.importDate) ?? importDate,
lastUsage:
row.lastUsage ??
getStringValue(base.lastUsage) ??
getStringValue(base.importDate) ??
importDate,
favorites,
recentlyViewed,
autoRefresh: row.autoRefresh ?? Boolean(base.autoRefresh),
url:
row.type === PLAYLIST_TYPES.M3U_URL
? (row.url ?? getStringValue(base.url))
: getStringValue(base.url),
filePath: row.filePath ?? getStringValue(base.filePath),
epgUrls,
detectedEpgUrls,
manualEpgUrls,
disabledEpgUrls,
userAgent: row.userAgent ?? getStringValue(base.userAgent),
referrer: row.referrer ?? getStringValue(base.referrer),
origin: row.origin ?? getStringValue(base.origin),
updateDate,
position: row.position ?? getNumericValue(base.position),
serverUrl: row.serverUrl ?? getStringValue(base.serverUrl),
username: row.username ?? getStringValue(base.username),
password: row.password ?? getStringValue(base.password),
macAddress: row.macAddress ?? getStringValue(base.macAddress),
portalUrl: portalUrl ?? getStringValue(base.portalUrl),
};
}
export async function createPlaylist(
db: AppDatabase,
playlist: {
@@ -351,7 +122,7 @@ export async function createPlaylist(
url: playlist.url,
portalUrl:
playlist.type === PLAYLIST_TYPES.STALKER ? playlist.url : undefined,
type: playlist.type as PlaylistType,
type: playlist.type as AppPlaylistType,
});
return { success: true };
@@ -30,6 +30,26 @@ describe('ExternalPlayerSessionRegistry', () => {
expect(updates.at(-1)?.status).toBe('opened');
});
it('numbers sessions in the order they began', () => {
const first = registry.beginSession({
player: 'mpv',
title: 'First',
streamUrl: 'https://example.com/one.m3u8',
});
const second = registry.beginSession({
player: 'mpv',
title: 'Second',
streamUrl: 'https://example.com/two.m3u8',
});
expect(first.launchOrder).toEqual(expect.any(Number));
expect(second.launchOrder).toBeGreaterThan(first.launchOrder ?? 0);
// Later updates keep the order the session began with.
expect(registry.markOpened(first.id)?.launchOrder).toBe(
first.launchOrder
);
});
it('keeps close capability and closes the session explicitly', async () => {
const close = jest.fn();
const session = registry.beginSession({
@@ -144,9 +164,9 @@ describe('ExternalPlayerSessionRegistry', () => {
registry.markClosed(closed.id);
expect(registry.markPlaying(closed.id)?.status).toBe('closed');
expect(
registry.markError(closed.id, 'Late process exit')?.status
).toBe('closed');
expect(registry.markError(closed.id, 'Late process exit')?.status).toBe(
'closed'
);
expect(updates.at(-1)).toMatchObject({
id: closed.id,
status: 'closed',
@@ -44,6 +44,7 @@ function isRestorableSession(session: ExternalPlayerSession): boolean {
export class ExternalPlayerSessionRegistry {
private readonly sessions = new Map<string, ExternalPlayerSessionRuntime>();
private activeSessionId: string | null = null;
private launchCount = 0;
constructor(
private readonly onUpdate: (session: ExternalPlayerSession) => void
@@ -63,6 +64,7 @@ export class ExternalPlayerSessionRegistry {
contentInfo: options.contentInfo,
startedAt,
updatedAt: startedAt,
launchOrder: ++this.launchCount,
canClose: false,
};
@@ -0,0 +1,298 @@
jest.mock('net', () => ({
createConnection: jest.fn(),
}));
jest.mock('./external-player-runtime', () => ({
traceExternalPlayer: jest.fn(),
}));
import { EventEmitter } from 'events';
import { createConnection } from 'net';
import {
MPV_IPC_COMMAND_TIMEOUT_MS,
MpvIpcCommandError,
sendMpvCommand,
} from './mpv-ipc-command';
type SocketMock = EventEmitter & {
write: jest.Mock;
destroy: jest.Mock;
destroyed: boolean;
};
const createConnectionMock = createConnection as unknown as jest.Mock;
const socketPath = '/tmp/mpv-ipc-command.sock';
const streamUrl = 'https://example.com/stream.m3u8?token=secret-token';
function nextSocket(): SocketMock {
const socket: SocketMock = Object.assign(new EventEmitter(), {
write: jest.fn(() => true),
destroy: jest.fn(() => {
socket.destroyed = true;
}),
destroyed: false,
});
createConnectionMock.mockReturnValueOnce(socket);
return socket;
}
function writtenRequest(socket: SocketMock): {
command: Array<string | number>;
request_id: number;
} {
return JSON.parse(String(socket.write.mock.calls[0][0]).trim());
}
function reply(socket: SocketMock, ...lines: Array<object | string>): void {
socket.emit(
'data',
Buffer.from(
lines
.map((line) =>
typeof line === 'string' ? line : JSON.stringify(line)
)
.join('\n') + '\n'
)
);
}
async function settle(): Promise<void> {
await new Promise<void>((resolve) => setImmediate(resolve));
}
describe('sendMpvCommand', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.useRealTimers();
});
it('sends a request_id and resolves with the matching reply', async () => {
const socket = nextSocket();
const pending = sendMpvCommand(socketPath, 'loadfile', [
streamUrl,
'replace',
-1,
'start=0',
]);
socket.emit('connect');
const request = writtenRequest(socket);
expect(request.command).toEqual([
'loadfile',
streamUrl,
'replace',
-1,
'start=0',
]);
expect(Number.isInteger(request.request_id)).toBe(true);
expect(String(socket.write.mock.calls[0][0]).endsWith('\n')).toBe(true);
reply(socket, {
data: { playlist_entry_id: 1 },
request_id: request.request_id,
error: 'success',
});
await expect(pending).resolves.toEqual({
request_id: request.request_id,
error: 'success',
data: { playlist_entry_id: 1 },
});
expect(socket.destroy).toHaveBeenCalledTimes(1);
});
it('gives consecutive commands distinct request ids', async () => {
const first = nextSocket();
const second = nextSocket();
const firstPending = sendMpvCommand(socketPath, 'set_property', [
'user-agent',
'agent',
]);
const secondPending = sendMpvCommand(socketPath, 'set_property', [
'referrer',
'https://example.com/',
]);
first.emit('connect');
second.emit('connect');
const firstId = writtenRequest(first).request_id;
const secondId = writtenRequest(second).request_id;
expect(secondId).not.toBe(firstId);
reply(first, { request_id: firstId, error: 'success' });
reply(second, { request_id: secondId, error: 'success' });
await expect(firstPending).resolves.toMatchObject({
request_id: firstId,
});
await expect(secondPending).resolves.toMatchObject({
request_id: secondId,
});
});
it('resolves false without writing when dispatch is vetoed', async () => {
const socket = nextSocket();
const onDispatched = jest.fn();
const pending = sendMpvCommand(socketPath, 'loadfile', [streamUrl], {
shouldDispatch: () => false,
onDispatched,
});
socket.emit('connect');
await expect(pending).resolves.toBe(false);
expect(socket.write).not.toHaveBeenCalled();
expect(onDispatched).not.toHaveBeenCalled();
expect(socket.destroy).toHaveBeenCalledTimes(1);
});
it('rejects with mpv error text after the command was dispatched', async () => {
const socket = nextSocket();
const onDispatched = jest.fn();
const pending = sendMpvCommand(socketPath, 'seek', ['10', 'absolute'], {
onDispatched,
});
socket.emit('connect');
expect(onDispatched).toHaveBeenCalledTimes(1);
reply(socket, {
request_id: writtenRequest(socket).request_id,
error: 'error running command',
});
await expect(pending).rejects.toMatchObject({
name: 'MpvIpcCommandError',
command: 'seek',
dispatched: true,
mpvError: 'error running command',
message: 'mpv seek: error running command',
});
expect(socket.destroy).toHaveBeenCalledTimes(1);
});
it.each([
['a missing error', {}],
['a non-string error', { error: 0 }],
])(
'rejects a matching reply with %s instead of confirming it',
async (_label, fields) => {
const socket = nextSocket();
const pending = sendMpvCommand(socketPath, 'loadfile', [streamUrl]);
socket.emit('connect');
reply(socket, {
request_id: writtenRequest(socket).request_id,
data: { playlist_entry_id: 1 },
...fields,
});
await expect(pending).rejects.toMatchObject({
name: 'MpvIpcCommandError',
dispatched: true,
mpvError: null,
message: 'mpv loadfile: malformed reply',
});
}
);
it('rejects an undispatched command on a socket error without echoing arguments', async () => {
const socket = nextSocket();
const pending = sendMpvCommand(socketPath, 'loadfile', [streamUrl]);
socket.emit('error', new Error('connect ENOENT'));
const error = (await pending.catch((caught) => caught)) as unknown;
expect(error).toBeInstanceOf(MpvIpcCommandError);
expect(error).toMatchObject({
dispatched: false,
mpvError: null,
message: 'mpv loadfile: connect ENOENT',
});
expect(socket.write).not.toHaveBeenCalled();
expect(String((error as Error).message)).not.toContain('secret');
});
it('rejects when the socket closes before a reply', async () => {
const socket = nextSocket();
const pending = sendMpvCommand(socketPath, 'quit', []);
socket.emit('connect');
socket.emit('close');
await expect(pending).rejects.toMatchObject({
command: 'quit',
dispatched: true,
message: 'mpv quit: socket closed before reply',
});
});
it('times out a reply that never comes', async () => {
jest.useFakeTimers();
const socket = nextSocket();
const pending = sendMpvCommand(socketPath, 'loadfile', [streamUrl]);
socket.emit('connect');
let settled = false;
void pending.catch(() => (settled = true));
await jest.advanceTimersByTimeAsync(MPV_IPC_COMMAND_TIMEOUT_MS - 1);
expect(settled).toBe(false);
await jest.advanceTimersByTimeAsync(1);
expect(settled).toBe(true);
await expect(pending).rejects.toMatchObject({
dispatched: true,
message: 'mpv loadfile: reply timed out',
});
expect(socket.destroy).toHaveBeenCalledTimes(1);
});
it('skips events, foreign replies and malformed lines, and joins split chunks', async () => {
const socket = nextSocket();
const pending = sendMpvCommand(socketPath, 'loadfile', [streamUrl]);
socket.emit('connect');
const requestId = writtenRequest(socket).request_id;
let settled = false;
void pending.then(
() => (settled = true),
() => (settled = true)
);
reply(
socket,
{ event: 'start-file', playlist_entry_id: 1 },
{
event: 'end-file',
reason: 'error',
file_error: 'loading failed',
},
{ request_id: requestId + 1, error: 'error running command' },
'not json',
'42'
);
await settle();
expect(settled).toBe(false);
const ownReply = JSON.stringify({
request_id: requestId,
error: 'success',
});
socket.emit('data', Buffer.from(ownReply.slice(0, 10)));
await settle();
expect(settled).toBe(false);
socket.emit('data', Buffer.from(ownReply.slice(10) + '\n'));
await expect(pending).resolves.toMatchObject({
request_id: requestId,
error: 'success',
});
});
it('ignores data that arrives after settling', async () => {
const socket = nextSocket();
const pending = sendMpvCommand(socketPath, 'quit', []);
socket.emit('connect');
const requestId = writtenRequest(socket).request_id;
reply(socket, { request_id: requestId, error: 'success' });
await expect(pending).resolves.toMatchObject({ error: 'success' });
expect(() =>
reply(socket, { request_id: requestId, error: 'late failure' })
).not.toThrow();
expect(() => socket.emit('close')).not.toThrow();
expect(socket.destroy).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,158 @@
import { createConnection } from 'net';
import { traceExternalPlayer } from './external-player-runtime';
/**
* Bounds connect, write and the wait for mpv's reply. It matches the IPC
* quit bound the reuse teardown relies on before its exit windows start.
*/
export const MPV_IPC_COMMAND_TIMEOUT_MS = 2_000;
/** One newline-delimited JSON reply of mpv's IPC protocol. */
export interface MpvIpcReply {
request_id: number;
error: string;
data?: unknown;
}
export interface MpvIpcCommandOptions {
/** Checked once connected; a `false` skips the write and resolves `false`. */
shouldDispatch?: () => boolean;
/** Runs right after the command was written, before any reply. */
onDispatched?: () => void;
}
/**
* A command mpv answered with an error, or one whose reply never came.
* `dispatched` tells whether the command reached the socket, so callers can
* treat the player state as possibly changed. The message never carries
* command arguments, which may include stream URLs.
*/
export class MpvIpcCommandError extends Error {
constructor(
readonly command: string,
message: string,
readonly dispatched: boolean,
readonly mpvError: string | null = null
) {
super(`mpv ${command}: ${message}`);
this.name = 'MpvIpcCommandError';
}
}
let nextRequestId = 1;
function parseReplyLine(line: string): Partial<MpvIpcReply> | null {
try {
const parsed: unknown = JSON.parse(line);
return parsed && typeof parsed === 'object'
? (parsed as Partial<MpvIpcReply>)
: null;
} catch {
return null;
}
}
/**
* Sends one command to a reused mpv over its IPC socket and resolves with
* mpv's matching reply. Every command carries a `request_id`; event lines
* and replies to other requests on the same connection are skipped. Resolves
* `false` when `shouldDispatch` vetoed the write, and rejects with
* {@link MpvIpcCommandError} on an error reply, a missing reply, or a socket
* failure.
*/
export function sendMpvCommand(
socketPath: string,
command: string,
args: Array<string | number>,
options: MpvIpcCommandOptions = {}
): Promise<MpvIpcReply | false> {
return new Promise((resolve, reject) => {
const requestId = nextRequestId++;
const client = createConnection(socketPath);
const request =
JSON.stringify({
command: [command, ...args],
request_id: requestId,
}) + '\n';
let settled = false;
let dispatched = false;
let buffered = '';
let timeoutHandle: NodeJS.Timeout | null = null;
const settle = (
outcome: { reply: MpvIpcReply | false } | { error: Error }
) => {
if (settled) return;
settled = true;
if (timeoutHandle) clearTimeout(timeoutHandle);
if (!client.destroyed) client.destroy();
if ('error' in outcome) {
reject(outcome.error);
} else {
resolve(outcome.reply);
}
};
const fail = (message: string, mpvError: string | null = null) =>
settle({
error: new MpvIpcCommandError(
command,
message,
dispatched,
mpvError
),
});
const consumeLine = (line: string) => {
const reply = parseReplyLine(line);
if (!reply || reply.request_id !== requestId) return;
// mpv always answers with a string `error`; anything else is not
// a confirmation and must not skip the fallback.
if (typeof reply.error !== 'string') {
fail('malformed reply');
return;
}
const error = reply.error;
traceExternalPlayer('mpv ipc reply', { command, error });
if (error === 'success') {
settle({
reply: { request_id: requestId, error, data: reply.data },
});
} else {
fail(error, error);
}
};
client.on('connect', () => {
if (options.shouldDispatch && !options.shouldDispatch()) {
settle({ reply: false });
return;
}
traceExternalPlayer('mpv ipc command', {
command,
argsCount: args.length,
});
try {
client.write(request);
dispatched = true;
options.onDispatched?.();
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
});
client.on('data', (chunk) => {
if (settled) return;
buffered += chunk.toString();
const lines = buffered.split('\n');
buffered = lines.pop() ?? '';
for (const line of lines) {
if (line.trim()) consumeLine(line);
if (settled) return;
}
});
client.on('error', (error) => fail(error.message));
client.on('close', () => fail('socket closed before reply'));
timeoutHandle = setTimeout(
() => fail('reply timed out'),
MPV_IPC_COMMAND_TIMEOUT_MS
);
timeoutHandle.unref();
});
}
@@ -0,0 +1,55 @@
import { EventEmitter } from 'events';
export type MpvSocketMock = EventEmitter & {
write: jest.Mock;
end: jest.Mock;
destroy: jest.Mock;
};
export interface MpvSocketMockOptions {
/** Collects every chunk written to the socket. */
written?: string[];
/** Emit `connect` on the next tick; `false` leaves that to the test. */
connect?: boolean;
/**
* mpv's `error` field for a written command, `'success'` by default.
* `null` leaves the command unanswered.
*/
reply?: (command: Array<string | number>) => string | null;
}
interface MpvIpcRequest {
command: Array<string | number>;
request_id: number;
}
/**
* A reused-instance mpv IPC socket: records writes and answers each command
* on the next tick with a reply carrying its `request_id`, as mpv does.
*/
export function createMpvSocketMock({
written,
connect = true,
reply = () => 'success',
}: MpvSocketMockOptions = {}): MpvSocketMock {
const socket: MpvSocketMock = Object.assign(new EventEmitter(), {
write: jest.fn((chunk: string) => {
written?.push(chunk);
const request = JSON.parse(chunk.trim()) as MpvIpcRequest;
const error = reply(request.command);
if (error !== null) {
const line =
JSON.stringify({ request_id: request.request_id, error }) +
'\n';
setImmediate(() => socket.emit('data', Buffer.from(line)));
}
return true;
}),
end: jest.fn(),
destroy: jest.fn(),
});
if (connect) {
setImmediate(() => socket.emit('connect'));
}
return socket;
}
@@ -0,0 +1,856 @@
/**
* MPV launch queue: launches run one at a time, only the newest queued one
* runs, and Stop on a queued launch delegates to the in-flight launch's and
* the stored player's own closers. Reply handling lives in
* `mpv-session.service.ipc-reply.spec.ts`.
*/
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn(),
},
}));
jest.mock('child_process', () => ({
spawn: jest.fn(),
}));
jest.mock('net', () => ({
createConnection: jest.fn(),
createServer: jest.fn(),
}));
jest.mock('../app', () => ({
__esModule: true,
default: {
mainWindow: null,
},
}));
jest.mock('../services/store.service', () => ({
MPV_PLAYER_ARGUMENTS: 'MPV_PLAYER_ARGUMENTS',
MPV_PLAYER_PATH: 'MPV_PLAYER_PATH',
MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE',
store: {
get: jest.fn(),
set: jest.fn(),
},
}));
import {
MPV_PLAYER_PATH,
MPV_REUSE_INSTANCE,
store,
} from '../services/store.service';
import { externalPlayerSessions } from './external-player-runtime';
import { MPV_IPC_COMMAND_TIMEOUT_MS } from './mpv-ipc-command';
import { MpvSocketMock } from './mpv-ipc-socket.test-helpers';
import {
openMpvPlayer,
setMpvReuseInstance,
shutdownMpvSession,
} from './mpv-session.service';
import {
spawnMock,
createMockChildProcess,
mockMpvSockets,
nextTick,
launchReusableSession,
mockUnansweredSockets,
mockSocketsQuittingInto,
commandsOf,
writtenRequestId,
} from './mpv-session.test-helpers';
describe('MPV launch queue', () => {
beforeEach(() => {
jest.clearAllMocks();
shutdownMpvSession();
});
afterEach(() => {
jest.useRealTimers();
shutdownMpvSession();
});
it('runs a quick second launch only after the first one settles', async () => {
const { proc } = await launchReusableSession();
const sockets = mockUnansweredSockets();
const commandOf = (socket: MpvSocketMock) =>
(
JSON.parse(String(socket.write.mock.calls[0][0])) as {
command: string[];
}
).command[0];
const answer = (socket: MpvSocketMock, id: number) =>
socket.emit(
'data',
Buffer.from(
JSON.stringify({ request_id: id, error: 'success' }) + '\n'
)
);
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const firstId = await writtenRequestId(sockets[0]);
const second = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
userAgent: 'IPTVnator test agent',
});
await nextTick();
await nextTick();
// The second launch waits in the queue: nothing else reached mpv.
expect(sockets).toHaveLength(1);
answer(sockets[0], firstId);
await expect(first).resolves.toMatchObject({ status: 'opened' });
answer(sockets[1], await writtenRequestId(sockets[1]));
while (sockets.length < 3) await nextTick();
answer(sockets[2], await writtenRequestId(sockets[2]));
await expect(second).resolves.toMatchObject({ status: 'opened' });
expect(sockets.map(commandOf)).toEqual([
'loadfile',
'set_property',
'loadfile',
]);
expect(proc.kill).not.toHaveBeenCalled();
expect(spawnMock).toHaveBeenCalledTimes(1);
});
it('lets Stop on a queued launch cancel it and stop the in-flight player', async () => {
const { proc } = await launchReusableSession();
const sockets = mockSocketsQuittingInto(proc);
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
await writtenRequestId(sockets[0]);
const second = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
// The queued launch is already the session the dock shows and stops.
const queuedId = externalPlayerSessions.getActiveSessionId() as string;
expect(externalPlayerSessions.getSession(queuedId)).toMatchObject({
title: 'Channel B',
status: 'launching',
canClose: true,
});
// Stop goes through the in-flight launch's own closer: quit, exit.
await expect(
externalPlayerSessions.closeSession(queuedId)
).resolves.toMatchObject({ id: queuedId, status: 'closed' });
expect(proc.exitCode).toBe(0);
// The quit player's socket closes before any reply.
sockets[0].emit('close');
await expect(first).resolves.toMatchObject({ status: 'closed' });
await expect(second).resolves.toMatchObject({
id: queuedId,
status: 'closed',
});
// Nothing started playing again after Stop.
expect(commandsOf(sockets)).toEqual(['loadfile', 'quit']);
expect(spawnMock).toHaveBeenCalledTimes(1);
});
it('lets Stop on the newest queued launch cancel every launch queued before it', async () => {
const { proc } = await launchReusableSession();
const sockets = mockSocketsQuittingInto(proc);
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
await writtenRequestId(sockets[0]);
const second = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const third = openMpvPlayer({
title: 'Channel C',
url: 'https://example.com/c.m3u8',
});
const newestId = externalPlayerSessions.getActiveSessionId() as string;
await expect(
externalPlayerSessions.closeSession(newestId)
).resolves.toMatchObject({ status: 'closed' });
sockets[0].emit('close');
await expect(first).resolves.toMatchObject({ status: 'closed' });
await expect(second).resolves.toMatchObject({ status: 'closed' });
await expect(third).resolves.toMatchObject({ status: 'closed' });
expect(proc.exitCode).toBe(0);
expect(commandsOf(sockets)).toEqual(['loadfile', 'quit']);
expect(spawnMock).toHaveBeenCalledTimes(1);
});
it('hands the dock back to the live player when a queued launch is blocked by its teardown', async () => {
const { proc } = await launchReusableSession();
// The first launch's loadfile fails and its teardown never sees the
// child exit, so the queued launch finds the teardown gate closed.
(proc.kill as jest.Mock).mockImplementation(() => true);
mockMpvSockets({
reply: (command) =>
command[0] === 'loadfile' ? 'error running command' : 'success',
});
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
jest.useFakeTimers();
try {
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const firstId =
externalPlayerSessions.getActiveSessionId() as string;
const firstRejection = expect(first).rejects.toThrow(
'External player process did not exit'
);
const second = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const secondId =
externalPlayerSessions.getActiveSessionId() as string;
const secondRejection = expect(second).rejects.toThrow(
'previous external player is still shutting down'
);
await jest.advanceTimersByTimeAsync(6_000);
await firstRejection;
await secondRejection;
// The session owning the still-running child is active again
// and can be stopped; the blocked launch is a terminal error.
expect(externalPlayerSessions.getActiveSessionId()).toBe(firstId);
expect(externalPlayerSessions.getSession(firstId)).toMatchObject({
status: 'error',
canClose: true,
});
expect(externalPlayerSessions.getSession(secondId)).toMatchObject({
status: 'error',
canClose: false,
});
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
errorSpy.mockRestore();
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
});
it('runs only the newest queued launch once the in-flight one settles', async () => {
await launchReusableSession();
const sockets = mockUnansweredSockets();
const answer = (socket: MpvSocketMock, id: number) =>
socket.emit(
'data',
Buffer.from(
JSON.stringify({ request_id: id, error: 'success' }) + '\n'
)
);
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const firstId = await writtenRequestId(sockets[0]);
const skipped = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const newest = openMpvPlayer({
title: 'Channel C',
url: 'https://example.com/c.m3u8',
});
const newestId = externalPlayerSessions.getActiveSessionId();
answer(sockets[0], firstId);
await expect(first).resolves.toMatchObject({ status: 'opened' });
// The skipped launch never reached mpv nor took the dock.
await expect(skipped).resolves.toMatchObject({ status: 'closed' });
expect(externalPlayerSessions.getActiveSessionId()).toBe(newestId);
answer(sockets[1], await writtenRequestId(sockets[1]));
await expect(newest).resolves.toMatchObject({
id: newestId,
status: 'opened',
});
expect(sockets).toHaveLength(2);
expect(spawnMock).toHaveBeenCalledTimes(1);
});
it('keeps a queued launch stoppable when its Stop cannot confirm the exit', async () => {
const { proc } = await launchReusableSession();
(proc.kill as jest.Mock).mockImplementation(() => true);
mockUnansweredSockets();
jest.useFakeTimers();
try {
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const queued = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const queuedId =
externalPlayerSessions.getActiveSessionId() as string;
await jest.advanceTimersByTimeAsync(0);
// Stop goes through the in-flight launch's closer: its quit gets
// no reply, and the bounded teardown cannot confirm the exit.
const closing = externalPlayerSessions.closeSession(queuedId);
const closeRejection = expect(closing).rejects.toThrow(
'External player process did not exit'
);
const firstRejection = expect(first).rejects.toThrow(
'External player process did not exit'
);
await jest.advanceTimersByTimeAsync(10_000);
await closeRejection;
await firstRejection;
await expect(queued).resolves.toMatchObject({
id: queuedId,
status: 'error',
canClose: true,
});
// A retried Stop reaches the still-running child again.
const killsBeforeRetry = (proc.kill as jest.Mock).mock.calls.length;
const retry = externalPlayerSessions.closeSession(queuedId);
await jest.advanceTimersByTimeAsync(
MPV_IPC_COMMAND_TIMEOUT_MS + 100
);
expect((proc.kill as jest.Mock).mock.calls.length).toBeGreaterThan(
killsBeforeRetry
);
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
await expect(retry).resolves.toMatchObject({
id: queuedId,
status: 'closed',
});
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
if (proc.exitCode === null) {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
}
});
it('lets a late Stop for an older queued launch cancel only that launch', async () => {
const { proc } = await launchReusableSession();
const sockets = mockUnansweredSockets();
const answer = (socket: MpvSocketMock, id: number) =>
socket.emit(
'data',
Buffer.from(
JSON.stringify({ request_id: id, error: 'success' }) + '\n'
)
);
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const firstId = await writtenRequestId(sockets[0]);
const older = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const olderId = externalPlayerSessions.getActiveSessionId() as string;
const newest = openMpvPlayer({
title: 'Channel C',
url: 'https://example.com/c.m3u8',
});
await expect(
externalPlayerSessions.closeSession(olderId)
).resolves.toMatchObject({ id: olderId, status: 'closed' });
expect(proc.kill).not.toHaveBeenCalled();
answer(sockets[0], firstId);
await expect(first).resolves.toMatchObject({ status: 'opened' });
await expect(older).resolves.toMatchObject({ status: 'closed' });
answer(sockets[1], await writtenRequestId(sockets[1]));
await expect(newest).resolves.toMatchObject({ status: 'opened' });
expect(commandsOf(sockets)).toEqual(['loadfile', 'loadfile']);
expect(proc.kill).not.toHaveBeenCalled();
});
it('settles a fresh launch and its queued Stop when the exit cannot be confirmed', async () => {
const proc = createMockChildProcess();
(proc.kill as jest.Mock).mockImplementation(() => {
Object.defineProperty(proc, 'killed', {
value: true,
configurable: true,
});
return true;
});
spawnMock.mockReturnValueOnce(proc);
(store.get as unknown as jest.Mock).mockImplementation(
(key: string, fallback?: unknown) =>
({
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
[MPV_REUSE_INSTANCE]: true,
})[key] ?? fallback
);
jest.useFakeTimers();
try {
// The first launch spawns fresh; the second queues during its
// start-up confirmation window.
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const queued = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const queuedId =
externalPlayerSessions.getActiveSessionId() as string;
// The Stop arrives over IPC, after the first launch spawned.
await jest.advanceTimersByTimeAsync(0);
let firstSettled = false;
void first.then(
() => (firstSettled = true),
() => (firstSettled = true)
);
const firstRejection = expect(first).rejects.toThrow(
'External player process did not exit'
);
const closeRejection = expect(
externalPlayerSessions.closeSession(queuedId)
).rejects.toThrow('External player process did not exit');
await jest.advanceTimersByTimeAsync(6_000);
expect(firstSettled).toBe(true);
await firstRejection;
await closeRejection;
await expect(queued).resolves.toMatchObject({
id: queuedId,
status: 'error',
canClose: true,
});
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
});
it('never lets a retried queued Stop reach a player started later', async () => {
const { proc } = await launchReusableSession();
(proc.kill as jest.Mock).mockImplementation(() => true);
const sockets = mockUnansweredSockets();
jest.useFakeTimers();
const later = createMockChildProcess();
try {
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const queued = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const queuedId =
externalPlayerSessions.getActiveSessionId() as string;
// The Stop arrives after the first launch wrote its loadfile.
while (!sockets[0]?.write.mock.calls.length) {
await jest.advanceTimersByTimeAsync(0);
}
const firstRejection = expect(first).rejects.toThrow(
'External player process did not exit'
);
const closeRejection = expect(
externalPlayerSessions.closeSession(queuedId)
).rejects.toThrow('External player process did not exit');
await jest.advanceTimersByTimeAsync(10_000);
await firstRejection;
await closeRejection;
await expect(queued).resolves.toMatchObject({ status: 'error' });
// The stuck child exits on its own and a new stream starts.
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
spawnMock.mockReturnValueOnce(later);
const next = openMpvPlayer({
title: 'Channel C',
url: 'https://example.com/c.m3u8',
});
await jest.advanceTimersByTimeAsync(100);
await expect(next).resolves.toMatchObject({ status: 'opened' });
// A retried Stop for the old queued launch stays on its own
// targets and leaves the new player alone.
const retry = externalPlayerSessions.closeSession(queuedId);
await jest.advanceTimersByTimeAsync(
MPV_IPC_COMMAND_TIMEOUT_MS + 100
);
await expect(retry).resolves.toMatchObject({ status: 'closed' });
expect(later.kill).not.toHaveBeenCalled();
} finally {
if (proc.exitCode === null) {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
}
});
it('restores the last started launch when a queued one is blocked by its teardown', async () => {
const proc = createMockChildProcess();
(proc.kill as jest.Mock).mockImplementation(() => {
Object.defineProperty(proc, 'killed', {
value: true,
configurable: true,
});
return true;
});
spawnMock.mockReturnValueOnce(proc);
// Reuse off: the detached child is never stored for reuse.
(store.get as unknown as jest.Mock).mockImplementation(
(key: string, fallback?: unknown) =>
({
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
[MPV_REUSE_INSTANCE]: false,
})[key] ?? fallback
);
jest.useFakeTimers();
try {
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const firstId =
externalPlayerSessions.getActiveSessionId() as string;
const skipped = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const newest = openMpvPlayer({
title: 'Channel C',
url: 'https://example.com/c.m3u8',
});
const newestId =
externalPlayerSessions.getActiveSessionId() as string;
const firstRejection = expect(first).rejects.toThrow(
'External player process did not exit'
);
const newestRejection = expect(newest).rejects.toThrow(
'previous external player is still shutting down'
);
// A delayed Stop for the first launch cannot confirm its exit.
const closeRejection = expect(
externalPlayerSessions.closeSession(firstId)
).rejects.toThrow('External player process did not exit');
await jest.advanceTimersByTimeAsync(6_000);
await closeRejection;
await firstRejection;
await expect(skipped).resolves.toMatchObject({ status: 'closed' });
await newestRejection;
// The dock goes back to the session owning the stuck child.
expect(externalPlayerSessions.getActiveSessionId()).toBe(firstId);
expect(externalPlayerSessions.getSession(firstId)).toMatchObject({
status: 'error',
canClose: true,
});
expect(externalPlayerSessions.getSession(newestId)).toMatchObject({
status: 'error',
canClose: false,
});
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
});
it('lets a queued Stop reach the child a failed launch is tearing down', async () => {
const { proc } = await launchReusableSession();
(proc.kill as jest.Mock).mockImplementation(() => true);
// The first launch's loadfile fails; its teardown then waits for an
// exit that never comes.
mockMpvSockets({
reply: (command) =>
command[0] === 'loadfile' ? 'error running command' : null,
});
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
jest.useFakeTimers();
try {
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const firstRejection = expect(first).rejects.toThrow(
'External player process did not exit'
);
const queued = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const queuedId =
externalPlayerSessions.getActiveSessionId() as string;
for (
let tick = 0;
tick < 50 && !(proc.kill as jest.Mock).mock.calls.length;
tick += 1
) {
await jest.advanceTimersByTimeAsync(10);
}
expect(proc.kill).toHaveBeenCalled();
// Stop during that teardown must not report success while the
// child is still alive.
const closeRejection = expect(
externalPlayerSessions.closeSession(queuedId)
).rejects.toThrow('External player process did not exit');
await jest.advanceTimersByTimeAsync(10_000);
await closeRejection;
await firstRejection;
await expect(queued).resolves.toMatchObject({
id: queuedId,
status: 'error',
canClose: true,
});
// A retry reaches the same child and settles once it exits; the
// retried quit gets no reply within its bound.
const retry = externalPlayerSessions.closeSession(queuedId);
await jest.advanceTimersByTimeAsync(0);
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
await jest.advanceTimersByTimeAsync(
MPV_IPC_COMMAND_TIMEOUT_MS + 100
);
await expect(retry).resolves.toMatchObject({
id: queuedId,
status: 'closed',
});
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
errorSpy.mockRestore();
if (proc.exitCode === null) {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
}
});
it('keeps a queued Stop working after the previous owner closed during a failed handback', async () => {
const { proc, session: previous } = await launchReusableSession();
(proc.kill as jest.Mock).mockImplementation(() => true);
const sockets = mockUnansweredSockets();
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
jest.useFakeTimers();
try {
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const firstRejection = expect(first).rejects.toThrow(
'External player process did not exit'
);
const loadfileId = await (async () => {
while (!sockets[0]?.write.mock.calls.length) {
await jest.advanceTimersByTimeAsync(0);
}
return (
JSON.parse(String(sockets[0].write.mock.calls[0][0])) as {
request_id: number;
}
).request_id;
})();
// A late Stop for the previous session closes it; the child now
// belongs to the first launch.
await expect(
externalPlayerSessions.closeSession(previous.id)
).resolves.toMatchObject({ status: 'closed' });
const queued = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const queuedId =
externalPlayerSessions.getActiveSessionId() as string;
// mpv rejects the load: the child is handed back to the closed
// previous session while the first launch tears it down.
sockets[0].emit(
'data',
Buffer.from(
JSON.stringify({
request_id: loadfileId,
error: 'error running command',
}) + '\n'
)
);
await jest.advanceTimersByTimeAsync(10);
const closeRejection = expect(
externalPlayerSessions.closeSession(queuedId)
).rejects.toThrow('External player process did not exit');
await jest.advanceTimersByTimeAsync(10_000);
await closeRejection;
await firstRejection;
await expect(queued).resolves.toMatchObject({
id: queuedId,
status: 'error',
canClose: true,
});
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
errorSpy.mockRestore();
if (proc.exitCode === null) {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
}
});
it('keeps a queued Stop working when reuse is disabled before the in-flight load', async () => {
const { proc } = await launchReusableSession();
(proc.kill as jest.Mock).mockImplementation(() => true);
const sockets = mockUnansweredSockets();
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
jest.useFakeTimers();
try {
// The first launch still waits on its user-agent reply, so the
// child still belongs to the session before it.
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
userAgent: 'IPTVnator test agent',
});
const firstRejection = expect(first).rejects.toThrow(
'External player process did not exit'
);
while (!sockets[0]?.write.mock.calls.length) {
await jest.advanceTimersByTimeAsync(0);
}
const queued = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const queuedId =
externalPlayerSessions.getActiveSessionId() as string;
// Disabling reuse clears the stored child before it has exited.
setMpvReuseInstance(false);
const closeRejection = expect(
externalPlayerSessions.closeSession(queuedId)
).rejects.toThrow('External player process did not exit');
await jest.advanceTimersByTimeAsync(10_000);
await closeRejection;
await firstRejection;
await expect(queued).resolves.toMatchObject({
id: queuedId,
status: 'error',
canClose: true,
});
// The first launch has settled; a retried Stop must still reach
// the live child and settle only once it exits.
const killsBeforeRetry = (proc.kill as jest.Mock).mock.calls.length;
const retry = externalPlayerSessions.closeSession(queuedId);
let retried = false;
void retry.then(
() => (retried = true),
() => (retried = true)
);
await jest.advanceTimersByTimeAsync(
MPV_IPC_COMMAND_TIMEOUT_MS + 100
);
expect(retried).toBe(false);
expect((proc.kill as jest.Mock).mock.calls.length).toBeGreaterThan(
killsBeforeRetry
);
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
await expect(retry).resolves.toMatchObject({
id: queuedId,
status: 'closed',
});
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
errorSpy.mockRestore();
if (proc.exitCode === null) {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
}
});
it('starts a queued launch on the fresh player a failed launch spawned', async () => {
const { proc } = await launchReusableSession();
const freshProc = createMockChildProcess();
spawnMock.mockReturnValueOnce(freshProc);
// The first launch's loadfile fails; every later command succeeds.
// Each loadfile records how many players had been spawned by then.
let rejectedLoadfile = false;
const spawnsAtLoadfile: number[] = [];
mockMpvSockets({
reply: (command) => {
if (command[0] === 'loadfile') {
spawnsAtLoadfile.push(spawnMock.mock.calls.length);
}
if (command[0] === 'loadfile' && !rejectedLoadfile) {
rejectedLoadfile = true;
return 'error running command';
}
return 'success';
},
});
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const first = openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
const second = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
await expect(first).resolves.toMatchObject({ status: 'opened' });
const secondSession = await second;
// The second launch loaded into the replacement the first one's
// fallback spawned, not into the child that fallback tore down.
expect(spawnsAtLoadfile).toEqual([1, 2]);
expect(
externalPlayerSessions.getSession(secondSession.id)?.status
).toBe('opened');
expect(proc.kill).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(freshProc.kill).not.toHaveBeenCalled();
} finally {
errorSpy.mockRestore();
}
});
});
@@ -0,0 +1,182 @@
import {
ExternalPlayerSession,
readExternalPlayerErrorCode,
} from '@iptvnator/shared/interfaces';
import { externalPlayerProcessTeardownGate } from './external-player-process';
import { externalPlayerSessions } from './external-player-runtime';
/** What a launch knows about its place in the queue when it starts. */
export interface QueuedMpvLaunch {
/** Whether this launch waited behind another one. */
queued: boolean;
/**
* Whether Stop or shutdown cancelled this launch while it waited, or a
* newer launch is queued behind it.
*/
cancelled: () => boolean;
/** This launch's own queued Stop, if one was requested. */
pendingStop: () => Promise<void> | null;
/** Called once the launch really starts acting on a player. */
markInFlight: () => void;
/** The last launch that started before this one runs. */
lastStartedSessionId: () => string | null;
}
/**
* Runs MPV launches one at a time. A reused launch waits for mpv's replies
* (bounded per command) and a failed one for its teardown and fallback
* spawn, so overlapping launches would act on the same child. Queued, each
* launch starts from the player the previous one left behind, and only the
* newest queued launch runs. An idle queue starts a launch synchronously.
*
* Until a queued launch starts, its Stop cancels it and every launch queued
* before it. Stop on the newest one also closes, through their own closers,
* the in-flight launch and the session owning the stored player, so the
* exact-child, retry and settle rules of those closers apply.
*/
export class MpvLaunchQueue {
private pending: Promise<unknown> | null = null;
private latest = 0;
private cancelledThrough = 0;
private inFlightSessionId: string | null = null;
// The last launch that actually started. With reuse off its detached
// child is not stored, so it is the only handle on that player once the
// launch settled with an unconfirmed teardown.
private lastStartedSessionId: string | null = null;
constructor(private readonly storedOwnerSessionId: () => string | null) {}
/** Cancels every launch queued so far, for app shutdown. */
cancelAll(): void {
this.cancelledThrough = this.latest;
}
enqueue(
session: ExternalPlayerSession,
launch: (queue: QueuedMpvLaunch) => Promise<ExternalPlayerSession>
): Promise<ExternalPlayerSession> {
const previous = this.pending;
const number = ++this.latest;
let stop: Promise<void> | null = null;
// Fixed at the first Stop, so a retry never reaches a newer player.
let stopTargets: string[] | null = null;
if (previous) {
externalPlayerSessions.attachCloser(session.id, () => {
this.cancelledThrough = Math.max(this.cancelledThrough, number);
// A late Stop for an older queued launch only cancels it.
stopTargets ??=
number === this.latest ? this.stopTargets(session.id) : [];
stop = Promise.all(
stopTargets.map((id) =>
externalPlayerSessions.closeSession(id)
)
).then(() => undefined);
return stop;
});
}
const queue: QueuedMpvLaunch = {
queued: previous !== null,
cancelled: () =>
number <= this.cancelledThrough || number !== this.latest,
pendingStop: () => stop,
markInFlight: () => {
this.inFlightSessionId = session.id;
this.lastStartedSessionId = session.id;
},
lastStartedSessionId: () => this.lastStartedSessionId,
};
const run = () => launch(queue);
// The queue is released before the caller sees the result, so a
// launch made right after this one settles starts synchronously.
let settled: Promise<unknown> | null = null;
const result = (previous ? previous.then(run) : run()).finally(() => {
if (this.inFlightSessionId === session.id) {
this.inFlightSessionId = null;
}
if (this.pending === settled) this.pending = null;
});
settled = result.catch(() => undefined);
this.pending = settled;
return result;
}
private stopTargets(sessionId: string): string[] {
const targets: string[] = [];
for (const id of [
this.inFlightSessionId,
this.storedOwnerSessionId(),
this.lastStartedSessionId,
]) {
// Only a session with its own closer can confirm its teardown;
// closing one without would just mark it closed.
if (
id &&
id !== sessionId &&
!targets.includes(id) &&
externalPlayerSessions.getSession(id)?.canClose
) {
targets.push(id);
}
}
return targets;
}
}
/**
* Settles a queued launch that will not start. When its own Stop is still
* closing the in-flight player, the outcome waits for it: a Stop that
* cannot confirm the exit leaves a closable error, so Stop can be retried.
*/
export async function settleCancelledQueuedLaunch(
session: ExternalPlayerSession,
pendingStop: Promise<void> | null
): Promise<ExternalPlayerSession> {
if (pendingStop) {
try {
await pendingStop;
} catch (error) {
return (
externalPlayerSessions.markError(
session.id,
error instanceof Error ? error.message : String(error),
{ canClose: true }
) ?? session
);
}
}
return externalPlayerSessions.markClosed(session.id) ?? session;
}
/**
* A queued launch starts after an earlier one may have left a child whose
* exit is unconfirmed. Then the dock goes back to the session that still
* owns that child; failing that, this session keeps its queued closer so
* Stop can still reach the child.
*/
export function assertQueuedLaunchAllowed(
session: ExternalPlayerSession,
restoreCandidates: Array<string | null>
): void {
try {
externalPlayerProcessTeardownGate.assertLaunchAllowed();
} catch (error) {
let restored: ExternalPlayerSession | null = null;
for (const id of restoreCandidates) {
if (!id || id === session.id) continue;
restored = externalPlayerSessions.restoreActiveSession(
id,
session.id
);
if (restored) break;
}
externalPlayerSessions.markError(
session.id,
error instanceof Error ? error.message : String(error),
{
code: readExternalPlayerErrorCode(error) ?? undefined,
canClose: !restored,
}
);
throw error;
}
}
@@ -0,0 +1,69 @@
import type { ChildProcess } from 'child_process';
import {
sendPlayerErrorNotification,
traceExternalPlayer,
} from './external-player-runtime';
function shouldIgnoreMpvStdoutLine(line: string): boolean {
return /^(\(Paused\)\s*)?AV:\s/.test(line);
}
/**
* Reports what a reusable MPV child prints: stream errors on stdout or stderr
* reach the renderer as player errors; the periodic status line is ignored.
*/
export function attachMpvOutputHandlers(proc: ChildProcess): void {
if (proc.stdout) {
proc.stdout.on('data', (data) => {
const lines = data
.toString()
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
for (const output of lines) {
if (shouldIgnoreMpvStdoutLine(output)) {
continue;
}
traceExternalPlayer('mpv stdout', { output });
if (
output.includes('Failed to open') ||
output.includes('Error opening') ||
output.includes('Protocol not found') ||
output.includes('Connection refused') ||
output.includes('error') ||
output.includes('403') ||
output.includes('404')
) {
console.error('[MPV ERROR from stdout]:', output);
sendPlayerErrorNotification('MPV', output);
}
}
});
}
if (proc.stderr) {
proc.stderr.on('data', (data) => {
const output = data.toString().trim();
if (output) {
console.error('[MPV stderr]:', output);
if (
output.includes('Failed to open') ||
output.includes('Exiting... (Errors when loading file)') ||
output.includes('Error opening') ||
output.includes('Protocol not found') ||
output.includes('Connection refused') ||
output.includes('error') ||
output.includes('403') ||
output.includes('404')
) {
console.error('[MPV ERROR]:', output);
sendPlayerErrorNotification('MPV', output);
}
}
});
}
}
@@ -0,0 +1,121 @@
import { createConnection } from 'net';
import { PlayerContentInfo } from '@iptvnator/shared/interfaces';
import { sendPlaybackPositionUpdate } from './external-player-runtime';
/**
* Playback-position polling of an external MPV over its IPC socket. One poll
* runs at a time across launches: starting a new one stops the previous one.
*/
let positionPollingInterval: NodeJS.Timeout | null = null;
// The first poll waits for MPV to open its IPC socket. The handle is kept so
// a player that exits during that wait does not start an orphaned interval.
let positionPollingDelay: NodeJS.Timeout | null = null;
// Bumped on every stop. A poll that was already awaiting an IPC reply when
// polling stopped (for example when a reused player loads the next stream)
// must not report what it reads under the previous content.
let positionPollingGeneration = 0;
async function getMpvProperty(
socketPath: string,
property: string
): Promise<number | null> {
return new Promise((resolve) => {
const client = createConnection(socketPath);
const request =
JSON.stringify({
command: ['get_property', property],
}) + '\n';
let data = '';
client.on('connect', () => {
client.write(request);
});
const timeoutHandle = setTimeout(() => {
client.destroy();
resolve(null);
}, 2000);
client.on('data', (chunk) => {
data += chunk.toString();
if (data.includes('\n')) {
clearTimeout(timeoutHandle);
try {
const lines = data.split('\n');
for (const line of lines) {
if (!line.trim()) continue;
const response = JSON.parse(line);
if (response.data !== undefined) {
client.destroy();
resolve(response.data);
return;
}
}
} catch {
// Ignore partial JSON while MPV is still writing.
}
}
});
client.on('end', () => {
clearTimeout(timeoutHandle);
if (data && !data.includes('\n')) {
try {
const response = JSON.parse(data);
resolve(response.data ?? null);
} catch {
resolve(null);
}
}
});
client.on('error', () => {
clearTimeout(timeoutHandle);
resolve(null);
});
});
}
export function stopPositionPolling(): void {
positionPollingGeneration += 1;
if (positionPollingDelay) {
clearTimeout(positionPollingDelay);
positionPollingDelay = null;
}
if (positionPollingInterval) {
clearInterval(positionPollingInterval);
positionPollingInterval = null;
}
}
export function startPositionPolling(
socketPath: string,
contentInfo: PlayerContentInfo,
sessionId: string
): void {
stopPositionPolling();
const generation = positionPollingGeneration;
const isCurrent = () => generation === positionPollingGeneration;
positionPollingDelay = setTimeout(() => {
positionPollingDelay = null;
positionPollingInterval = setInterval(async () => {
try {
const position = await getMpvProperty(socketPath, 'time-pos');
if (!isCurrent()) return;
const duration = await getMpvProperty(socketPath, 'duration');
if (!isCurrent()) return;
if (position !== null) {
sendPlaybackPositionUpdate(sessionId, contentInfo, {
positionSeconds: Math.floor(position),
durationSeconds: duration ? Math.floor(duration) : null,
});
}
} catch {
stopPositionPolling();
}
}, 5000);
}, 2000);
}
@@ -1,20 +1,22 @@
import { ChildProcess } from 'child_process';
import { createConnection } from 'net';
import {
ExternalPlayerSession,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { redactSensitiveData } from '@iptvnator/shared/logging';
import { joinMpvHeaderFields } from '../util/mpv-string-list.util';
import { externalPlayerProcessTeardownGate } from './external-player-process';
import {
externalPlayerSessions,
traceExternalPlayer,
} from './external-player-runtime';
import { sendMpvCommand } from './mpv-ipc-command';
/**
* The per-file start of a reuse load: the requested offset, else zero. A
* `seek` right after `loadfile` runs before the file is loaded and fails,
* so the offset travels with the load itself.
* `seek` right after `loadfile` runs before the file is loaded and mpv
* rejects it, so the offset travels with the load itself and no seek is
* sent on the reuse path.
*/
function reuseStartSeconds(startTime: number | undefined): number {
return typeof startTime === 'number' &&
@@ -24,7 +26,9 @@ function reuseStartSeconds(startTime: number | undefined): number {
: 0;
}
const MPV_IPC_COMMAND_TIMEOUT_MS = 2_000;
function hasExited(child: ChildProcess): boolean {
return child.exitCode !== null || child.signalCode !== null;
}
export interface MpvReuseAttemptState {
contentMutated: boolean;
@@ -50,68 +54,33 @@ interface MpvReuseOptions {
stopPositionPolling: () => void;
}
function sendMpvCommand(
socketPath: string,
command: string,
args: Array<string | number>,
shouldDispatch?: () => boolean
): Promise<boolean> {
return new Promise((resolve, reject) => {
const client = createConnection(socketPath);
const request = JSON.stringify({ command: [command, ...args] }) + '\n';
let settled = false;
let timeoutHandle: NodeJS.Timeout | null = null;
const complete = (error?: Error, dispatched = true) => {
if (settled) return;
settled = true;
if (timeoutHandle) clearTimeout(timeoutHandle);
if (!dispatched && !client.destroyed) client.destroy();
if (error) {
reject(error);
} else {
resolve(dispatched);
}
};
client.on('connect', () => {
if (shouldDispatch && !shouldDispatch()) {
complete(undefined, false);
return;
}
traceExternalPlayer('mpv ipc command', {
command,
argsCount: args.length,
});
try {
client.write(request);
client.end();
complete();
} catch (error) {
complete(
error instanceof Error ? error : new Error(String(error))
);
}
});
client.on('error', (error) => complete(error));
timeoutHandle = setTimeout(() => {
complete(new Error('MPV IPC command timed out'));
client.destroy();
}, MPV_IPC_COMMAND_TIMEOUT_MS);
timeoutHandle.unref();
});
}
/** Owns the one MPV child/socket retained when instance reuse is enabled. */
export class MpvReusableProcess {
private process: ChildProcess | null = null;
private socketPath: string | null = null;
private processSessionId: string | null = null;
private readonly processSessionIds = new WeakMap<ChildProcess, string>();
// Bumped whenever the stored player is stopped (reuse disabled or app
// quitting). Launches run one at a time, so the only thing that can
// overtake an in-flight reuse attempt is that stop, which cancels it.
private stopGeneration = 0;
currentSessionId(): string | null {
return this.processSessionId;
}
/**
* The session that owns the stored child right now. A failed reuse
* attempt hands the child back to its previous owner while it tears it
* down, so this reads the per-child mapping, not the last recorded id.
*/
storedOwnerSessionId(): string | null {
if (!this.process) return null;
return (
this.processSessionIds.get(this.process) ?? this.processSessionId
);
}
sessionIdFor(process: ChildProcess, fallback: string): string {
return this.processSessionIds.get(process) ?? fallback;
}
@@ -140,6 +109,10 @@ export class MpvReusableProcess {
stopPositionPolling: () => void,
guardFutureLaunches = false
): void {
// A launch still waiting for mpv's reply must not fall back to a
// fresh, again-reusable player once reuse is disabled or the app
// quits: cancel it so it settles its own session instead.
this.stopGeneration += 1;
const process = this.process;
if (!process || process.killed) return;
traceExternalPlayer(reason);
@@ -163,6 +136,29 @@ export class MpvReusableProcess {
traceExternalPlayer('reuse existing mpv instance');
const { session, state } = options;
const generation = this.stopGeneration;
const cancelled = () => generation !== this.stopGeneration;
// The stored-player stop is already tearing the child down; settle
// only once its exit is confirmed, else stay a closable error so Stop
// can retry while the teardown gate blocks further launches.
const settleCancelled = async () => {
traceExternalPlayer('mpv reuse cancelled by a stored-player stop');
try {
await externalPlayerProcessTeardownGate.terminate(
reusedProcess
);
} catch (error) {
state.teardownUnconfirmed = true;
return (
externalPlayerSessions.markError(
session.id,
error instanceof Error ? error.message : String(error),
{ canClose: true }
) ?? session
);
}
return externalPlayerSessions.markClosed(session.id) ?? session;
};
const reusedProcessSessionId =
this.processSessionIds.get(reusedProcess) ??
options.previousProcessSessionId;
@@ -170,7 +166,18 @@ export class MpvReusableProcess {
let retryableClose: Promise<void> | null = null;
let launchClose: Promise<void> | null = null;
// Launches run one at a time, so nothing else acts on this child while
// this attempt is in flight: its Stop always reaches the child then,
// whoever the per-child mapping names (the previous owner before
// loadfile or during a failed attempt's teardown, possibly closed
// already). Once the attempt settles, ownership decides as before.
let attemptInFlight = true;
// A Stop of this session that could not confirm the exit keeps the
// right to retry that exact child after the attempt settles.
let ownsFailedTeardown = false;
const ownsReusedProcess = () =>
attemptInFlight ||
ownsFailedTeardown ||
this.processSessionIds.get(reusedProcess) === session.id ||
externalPlayerSessions.getActiveSessionId() === session.id;
const closeReusedProcess = async () => {
@@ -208,6 +215,7 @@ export class MpvReusableProcess {
launchClose ??= closeAttempt;
void closeAttempt.catch((error) => {
if (retryableClose === closeAttempt) retryableClose = null;
ownsFailedTeardown = true;
state.teardownUnconfirmed = true;
externalPlayerSessions.markError(
session.id,
@@ -219,28 +227,34 @@ export class MpvReusableProcess {
return retryableClose;
});
try {
await this.applyReuseCommands(
options,
reusedSocketPath,
() => !closeRequested
);
if (closeRequested) return await finishRequestedClose();
// Once `loadfile` reaches the socket the player content may have
// changed, whatever mpv replies, so the attempted session owns the
// child from the write onwards: an exit or a stale Stop for the
// previous session during the reply wait then lands on the right
// session instead of terminating the new playback, and the previous
// session's position poll can no longer read the new stream's
// position under the old content.
const onContentDispatched = () => {
state.contentMutated = true;
this.processSessionId = session.id;
this.processSessionIds.set(reusedProcess, session.id);
options.stopPositionPolling();
};
if (options.startTime) {
await sendMpvCommand(
reusedSocketPath,
'seek',
[String(options.startTime), 'absolute'],
() => !closeRequested
);
}
try {
await this.applyReuseCommands(
options,
reusedSocketPath,
() => !closeRequested && !cancelled(),
onContentDispatched
);
if (closeRequested) return await finishRequestedClose();
if (cancelled()) return await settleCancelled();
if (hasExited(reusedProcess)) {
// The child exited while its reply was in flight; its exit
// handler has already settled the session that owned it.
return externalPlayerSessions.getSession(session.id) ?? session;
}
if (options.contentInfo) {
options.startPositionPolling(
@@ -256,7 +270,17 @@ export class MpvReusableProcess {
const current = externalPlayerSessions.getSession(session.id);
if (current?.status === 'closed') return current;
if (closeRequested) return await finishRequestedClose();
console.error('Failed to send command to existing MPV:', error);
if (cancelled()) return await settleCancelled();
if (current?.status === 'error' && hasExited(reusedProcess)) {
// The child died while its reply was in flight and its exit
// handler already reported the failure; a fresh launch under
// this terminal session could never be shown as opened.
return current;
}
console.error(
'Failed to send command to existing MPV:',
redactSensitiveData(error)
);
if (state.contentMutated) {
if (reusedProcessSessionId) {
@@ -282,21 +306,27 @@ export class MpvReusableProcess {
this.clear(reusedProcess);
options.stopPositionPolling();
if (closeRequested) return await finishRequestedClose();
// Reuse may have been disabled, or the app may be quitting,
// while the teardown above waited for the child to exit.
if (cancelled()) return await settleCancelled();
return null;
} finally {
attemptInFlight = false;
}
}
private async applyReuseCommands(
options: MpvReuseOptions,
socketPath: string,
shouldDispatch: () => boolean
shouldDispatch: () => boolean,
onContentDispatched: () => void
): Promise<void> {
if (options.effectiveUserAgent) {
const dispatched = await sendMpvCommand(
socketPath,
'set_property',
['user-agent', options.effectiveUserAgent],
shouldDispatch
{ shouldDispatch }
);
if (!dispatched) return;
}
@@ -305,7 +335,7 @@ export class MpvReusableProcess {
socketPath,
'set_property',
['referrer', options.effectiveReferer],
shouldDispatch
{ shouldDispatch }
);
if (!dispatched) return;
}
@@ -317,7 +347,7 @@ export class MpvReusableProcess {
'http-header-fields',
joinMpvHeaderFields(options.headerFields),
],
shouldDispatch
{ shouldDispatch }
);
if (!dispatched) return;
}
@@ -335,13 +365,13 @@ export class MpvReusableProcess {
-1,
fileOptions.join(','),
];
const dispatched = await sendMpvCommand(
const reply = await sendMpvCommand(
socketPath,
'loadfile',
loadFileArgs,
shouldDispatch
{ shouldDispatch, onDispatched: onContentDispatched }
);
if (!dispatched) return;
if (!reply) return;
traceExternalPlayer('loaded new url in existing mpv instance');
}
}
@@ -0,0 +1,663 @@
/**
* Reply handling of the reused-instance MPV IPC path. Every command carries a
* `request_id` and the launch waits, bounded, for mpv's matching reply; an
* error reply or a missing one falls back to a fresh launch exactly like a
* socket failure. Teardown, ownership and Stop-interleaving coverage lives in
* `mpv-session.service.spec.ts`.
*/
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn(),
},
}));
jest.mock('child_process', () => ({
spawn: jest.fn(),
}));
jest.mock('net', () => ({
createConnection: jest.fn(),
createServer: jest.fn(),
}));
jest.mock('../app', () => ({
__esModule: true,
default: {
mainWindow: null,
},
}));
jest.mock('../services/store.service', () => ({
MPV_PLAYER_ARGUMENTS: 'MPV_PLAYER_ARGUMENTS',
MPV_PLAYER_PATH: 'MPV_PLAYER_PATH',
MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE',
store: {
get: jest.fn(),
set: jest.fn(),
},
}));
import {
MPV_PLAYER_PATH,
MPV_REUSE_INSTANCE,
store,
} from '../services/store.service';
import * as externalPlayerRuntime from './external-player-runtime';
import { externalPlayerSessions } from './external-player-runtime';
import { MPV_IPC_COMMAND_TIMEOUT_MS } from './mpv-ipc-command';
import {
createMpvSocketMock,
MpvSocketMock,
} from './mpv-ipc-socket.test-helpers';
import {
openMpvPlayer,
setMpvReuseInstance,
shutdownMpvSession,
} from './mpv-session.service';
import {
spawnMock,
createConnectionMock,
createMockChildProcess,
mockMpvSockets,
nextTick,
launchReusableInstance,
launchReusableSession,
mockUnansweredSockets,
writtenRequestId,
} from './mpv-session.test-helpers';
describe('reused MPV IPC replies', () => {
beforeEach(() => {
jest.clearAllMocks();
shutdownMpvSession();
});
afterEach(() => {
jest.useRealTimers();
shutdownMpvSession();
});
it('opens the session once mpv acknowledges the content command', async () => {
const reusedProc = await launchReusableInstance();
const written: string[] = [];
mockMpvSockets({ written });
const session = await openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
startTime: 30,
});
expect(session.status).toBe('opened');
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(reusedProc.kill).not.toHaveBeenCalled();
const requests = written.map(
(chunk) =>
JSON.parse(chunk.trim()) as {
command: Array<string | number>;
request_id: number;
}
);
// The offset travels with loadfile; no seek races the load.
expect(requests.map((request) => request.command[0])).toEqual([
'loadfile',
]);
expect(Number.isInteger(requests[0].request_id)).toBe(true);
});
it('falls back to a fresh launch when mpv rejects the content command', async () => {
const reusedProc = await launchReusableInstance();
const freshProc = createMockChildProcess();
spawnMock.mockReturnValueOnce(freshProc);
mockMpvSockets({
reply: (command) =>
command[0] === 'loadfile' ? 'error running command' : 'success',
});
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const session = await openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8?token=secret-token',
});
expect(reusedProc.kill).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(session.status).toBe('opened');
expect(externalPlayerSessions.getSession(session.id)?.status).toBe(
'opened'
);
const logged = JSON.stringify(errorSpy.mock.calls);
expect(logged).toContain('mpv loadfile: error running command');
expect(logged).not.toContain('secret-token');
} finally {
errorSpy.mockRestore();
}
});
it('falls back to a fresh launch when mpv never replies', async () => {
const reusedProc = await launchReusableInstance();
const freshProc = createMockChildProcess();
spawnMock.mockReturnValueOnce(freshProc);
mockMpvSockets({ reply: () => null });
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
jest.useFakeTimers();
try {
const opening = openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
});
await jest.advanceTimersByTimeAsync(MPV_IPC_COMMAND_TIMEOUT_MS - 1);
expect(reusedProc.kill).not.toHaveBeenCalled();
expect(spawnMock).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(reusedProc.kill).toHaveBeenCalledTimes(1);
// Exit of the reused child, the fresh spawn and its start
// confirmation all run on timers.
await jest.advanceTimersByTimeAsync(500);
await expect(opening).resolves.toMatchObject({
status: 'opened',
});
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(JSON.stringify(errorSpy.mock.calls)).toContain(
'mpv loadfile: reply timed out'
);
} finally {
errorSpy.mockRestore();
}
});
it('ignores events and other replies while waiting for its own', async () => {
await launchReusableInstance();
const sockets: MpvSocketMock[] = [];
createConnectionMock.mockImplementation(() => {
const socket = createMpvSocketMock({ reply: () => null });
sockets.push(socket);
return socket;
});
const opening = openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
});
let settled = false;
void opening.then(
() => (settled = true),
() => (settled = true)
);
while (!sockets[0]?.write.mock.calls.length) {
await nextTick();
}
const { request_id: requestId } = JSON.parse(
sockets[0].write.mock.calls[0][0] as string
) as { request_id: number };
const push = (...lines: object[]) =>
sockets[0].emit(
'data',
Buffer.from(
lines.map((line) => JSON.stringify(line)).join('\n') + '\n'
)
);
// mpv broadcasts events on every connection and may still be
// answering an earlier request; none of these is this reply.
push(
{ event: 'start-file', playlist_entry_id: 1 },
{
event: 'end-file',
reason: 'error',
file_error: 'loading failed',
},
{ request_id: requestId + 1000, error: 'error running command' }
);
await nextTick();
await nextTick();
expect(settled).toBe(false);
expect(spawnMock).toHaveBeenCalledTimes(1);
push({ request_id: requestId, error: 'success' });
await expect(opening).resolves.toMatchObject({ status: 'opened' });
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(sockets).toHaveLength(1);
});
it('lets a stale Stop for the previous session pass while the loadfile reply is pending', async () => {
const { proc, session: previous } = await launchReusableSession();
const sockets = mockUnansweredSockets();
const opening = openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
});
const requestId = await writtenRequestId(sockets[0]);
// The child already belongs to the new session: the old closer
// must neither kill it nor send quit.
await expect(
externalPlayerSessions.closeSession(previous.id)
).resolves.toMatchObject({ id: previous.id, status: 'closed' });
expect(proc.kill).not.toHaveBeenCalled();
expect(sockets).toHaveLength(1);
sockets[0].emit(
'data',
Buffer.from(
JSON.stringify({ request_id: requestId, error: 'success' }) +
'\n'
)
);
await expect(opening).resolves.toMatchObject({ status: 'opened' });
expect(proc.kill).not.toHaveBeenCalled();
expect(spawnMock).toHaveBeenCalledTimes(1);
});
it('settles the new session as closed when the child exits during the reply wait', async () => {
const { proc, session: previous } = await launchReusableSession();
const sockets = mockUnansweredSockets();
const opening = openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 7,
contentType: 'vod',
},
});
const requestId = await writtenRequestId(sockets[0]);
const replacementId =
externalPlayerSessions.getActiveSessionId() as string;
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
sockets[0].emit(
'data',
Buffer.from(
JSON.stringify({ request_id: requestId, error: 'success' }) +
'\n'
)
);
// The exit landed on the session that owned the child at that
// moment, and a dead player is never reported as opened.
await expect(opening).resolves.toMatchObject({
id: replacementId,
status: 'closed',
});
expect(externalPlayerSessions.getSession(previous.id)?.status).toBe(
'opened'
);
expect(spawnMock).toHaveBeenCalledTimes(1);
});
it('stops the previous position poll as soon as loadfile is written', async () => {
jest.useFakeTimers();
const contentInfo = {
playlistId: 'playlist-1',
contentXtreamId: 7,
contentType: 'vod' as const,
};
const proc = createMockChildProcess();
spawnMock.mockReturnValueOnce(proc);
(store.get as unknown as jest.Mock).mockImplementation(
(key: string, fallback?: unknown) =>
({
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
[MPV_REUSE_INSTANCE]: true,
})[key] ?? fallback
);
const sockets = mockUnansweredSockets();
const first = openMpvPlayer({
title: 'First stream',
url: 'https://example.com/one.m3u8',
contentInfo,
});
await jest.advanceTimersByTimeAsync(100);
await first;
// The first session polls 2 s after launch, then every 5 s: the
// next poll is due at 7 s, inside the reply window of a loadfile
// written at 6.5 s.
await jest.advanceTimersByTimeAsync(6_500);
const pollsBefore = sockets.length;
const opening = openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
contentInfo: { ...contentInfo, contentXtreamId: 8 },
});
await jest.advanceTimersByTimeAsync(0);
const loadfileSocket = sockets[pollsBefore];
const requestId = await writtenRequestId(loadfileSocket);
await jest.advanceTimersByTimeAsync(1_000);
const commandsAfterLoadfile = sockets
.slice(pollsBefore + 1)
.flatMap((socket) =>
socket.write.mock.calls.map(
([request]) =>
(JSON.parse(String(request)) as { command: string[] })
.command[0]
)
);
expect(commandsAfterLoadfile).not.toContain('get_property');
loadfileSocket.emit(
'data',
Buffer.from(
JSON.stringify({ request_id: requestId, error: 'success' }) +
'\n'
)
);
await jest.advanceTimersByTimeAsync(0);
await expect(opening).resolves.toMatchObject({ status: 'opened' });
});
it('drops a position read that was in flight when loadfile was written', async () => {
// The previous test's player exits on a real tick, and any MPV exit
// stops polling; let it land before this test starts its poll.
await nextTick();
await nextTick();
jest.useFakeTimers();
const sendPosition = jest.spyOn(
externalPlayerRuntime,
'sendPlaybackPositionUpdate'
);
const proc = createMockChildProcess();
spawnMock.mockReturnValueOnce(proc);
(store.get as unknown as jest.Mock).mockImplementation(
(key: string, fallback?: unknown) =>
({
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
[MPV_REUSE_INSTANCE]: true,
})[key] ?? fallback
);
const sockets = mockUnansweredSockets();
const commandOf = (socket: MpvSocketMock) =>
socket.write.mock.calls.length
? (
JSON.parse(String(socket.write.mock.calls[0][0])) as {
command: string[];
}
).command
: [];
const first = openMpvPlayer({
title: 'First stream',
url: 'https://example.com/one.m3u8',
contentInfo: {
playlistId: 'playlist-1',
contentXtreamId: 7,
contentType: 'vod',
},
});
await jest.advanceTimersByTimeAsync(100);
const previous = await first;
// The first poll fires 2 s + 5 s after launch and waits for time-pos.
await jest.advanceTimersByTimeAsync(6_950);
const timePosSocket = sockets.find(
(socket) => commandOf(socket)[1] === 'time-pos'
);
expect(timePosSocket).toBeDefined();
const opening = openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
});
await jest.advanceTimersByTimeAsync(0);
const loadfileSocket = sockets.find(
(socket) => commandOf(socket)[0] === 'loadfile'
) as MpvSocketMock;
const requestId = await writtenRequestId(loadfileSocket);
// The pending read now returns the new stream's position.
timePosSocket?.emit(
'data',
Buffer.from(JSON.stringify({ data: 12.5, error: 'success' }) + '\n')
);
await jest.advanceTimersByTimeAsync(10);
loadfileSocket.emit(
'data',
Buffer.from(
JSON.stringify({ request_id: requestId, error: 'success' }) +
'\n'
)
);
await jest.advanceTimersByTimeAsync(0);
await expect(opening).resolves.toMatchObject({ status: 'opened' });
// An unguarded poll would now ask for the duration and, once that
// read times out, report the new position under the old content.
await jest.advanceTimersByTimeAsync(MPV_IPC_COMMAND_TIMEOUT_MS + 100);
expect(
sockets.some((socket) => commandOf(socket)[1] === 'duration')
).toBe(false);
expect(sendPosition).not.toHaveBeenCalledWith(
previous.id,
expect.anything(),
expect.anything()
);
sendPosition.mockRestore();
});
it('keeps a reuse cancelled by disabling reuse stoppable until its child exits', async () => {
const { proc } = await launchReusableSession();
(proc.kill as jest.Mock).mockImplementation(() => true);
const sockets = mockUnansweredSockets();
jest.useFakeTimers();
try {
const opening = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
while (!sockets[0]?.write.mock.calls.length) {
await jest.advanceTimersByTimeAsync(0);
}
setMpvReuseInstance(false);
sockets[0].emit('close');
await jest.advanceTimersByTimeAsync(6_000);
// The child never confirmed its exit: the session stays a
// closable error instead of a closed one Stop cannot retry.
await expect(opening).resolves.toMatchObject({
status: 'error',
canClose: true,
});
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
});
it('keeps Stop working for a reused session restored after a later attempt fails', async () => {
const { proc } = await launchReusableSession();
// Exit is never confirmed, so a failing later attempt cannot finish
// its teardown and the previous owner is restored.
(proc.kill as jest.Mock).mockImplementation(() => true);
mockMpvSockets();
const restored = await openMpvPlayer({
title: 'Channel A',
url: 'https://example.com/a.m3u8',
});
expect(restored.status).toBe('opened');
createConnectionMock.mockImplementation(() => {
const socket = createMpvSocketMock({ connect: false });
setImmediate(() =>
socket.emit('error', new Error('reuse command failed'))
);
return socket;
});
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
jest.useFakeTimers();
try {
const failing = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
const rejection = expect(failing).rejects.toThrow(
'External player process did not exit'
);
await jest.advanceTimersByTimeAsync(6_000);
await rejection;
expect(externalPlayerSessions.getActiveSessionId()).toBe(
restored.id
);
const written: string[] = [];
mockMpvSockets({ written });
let closed = false;
const closing = externalPlayerSessions
.closeSession(restored.id)
.then((session) => {
closed = true;
return session;
});
await jest.advanceTimersByTimeAsync(10);
// Stop reaches the child and waits for its exit.
expect(
written.map(
(chunk) =>
(JSON.parse(chunk.trim()) as { command: string[] })
.command[0]
)
).toContain('quit');
expect(closed).toBe(false);
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
await expect(closing).resolves.toMatchObject({
id: restored.id,
status: 'closed',
});
} finally {
errorSpy.mockRestore();
if (proc.exitCode === null) {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
}
}
});
it.each([
['reuse is disabled', () => setMpvReuseInstance(false)],
['the app shuts down', () => shutdownMpvSession()],
])(
'does not fall back to a fresh player when %s during the reply wait',
async (_label, stopStored) => {
const { proc } = await launchReusableSession();
// The kill is sent but its exit is reported only later, after
// the socket close already rejected the pending loadfile.
(proc.kill as jest.Mock).mockImplementation(() => true);
const sockets = mockUnansweredSockets();
const opening = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
await writtenRequestId(sockets[0]);
stopStored();
sockets[0].emit('close');
await nextTick();
await nextTick();
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
await expect(opening).resolves.toMatchObject({
status: 'closed',
});
expect(spawnMock).toHaveBeenCalledTimes(1);
}
);
it.each([
['reuse is disabled', () => setMpvReuseInstance(false)],
['the app shuts down', () => shutdownMpvSession()],
])(
'does not fall back to a fresh player when %s during the failed-reuse teardown',
async (_label, stopStored) => {
const { proc } = await launchReusableSession();
// mpv rejects the content command, and the teardown that
// follows waits for an exit that is reported only later.
(proc.kill as jest.Mock).mockImplementation(() => true);
mockMpvSockets({
reply: (command) =>
command[0] === 'loadfile'
? 'error running command'
: 'success',
});
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const opening = openMpvPlayer({
title: 'Channel B',
url: 'https://example.com/b.m3u8',
});
while (!(proc.kill as jest.Mock).mock.calls.length) {
await nextTick();
}
stopStored();
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
await expect(opening).resolves.toMatchObject({
status: 'closed',
});
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
errorSpy.mockRestore();
}
}
);
it('keeps the errored session when the child crashes during the reply wait', async () => {
const { proc, session: previous } = await launchReusableSession();
const sockets = mockUnansweredSockets();
const errorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const opening = openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
});
await writtenRequestId(sockets[0]);
const replacementId =
externalPlayerSessions.getActiveSessionId() as string;
// A crash closes the IPC socket before any reply: the exit
// handler reports it, and no fresh player is launched under a
// session that could never be shown as opened.
Object.defineProperty(proc, 'exitCode', { value: 1 });
proc.emit('exit', 1);
sockets[0].emit('close');
await expect(opening).resolves.toMatchObject({
id: replacementId,
status: 'error',
errorCode: 'closed-unexpectedly',
});
expect(externalPlayerSessions.getSession(previous.id)?.status).toBe(
'opened'
);
expect(spawnMock).toHaveBeenCalledTimes(1);
} finally {
errorSpy.mockRestore();
}
});
});
@@ -61,6 +61,11 @@ import {
setVlcReuseInstance,
shutdownVlcSession,
} from './vlc-session.service';
import {
createMpvSocketMock,
MpvSocketMock,
MpvSocketMockOptions,
} from './mpv-ipc-socket.test-helpers';
function createMockChildProcess(): ChildProcess {
return Object.assign(new EventEmitter(), {
@@ -95,6 +100,13 @@ function mockStoreValues(values: Record<string, unknown>): void {
);
}
/** Every reused-instance IPC connection answers like a healthy mpv. */
function mockMpvSockets(options: MpvSocketMockOptions = {}): void {
(createConnection as unknown as jest.Mock).mockImplementation(() =>
createMpvSocketMock(options)
);
}
describe('external player shutdown on app quit', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -263,15 +275,7 @@ describe('external player shutdown on app quit', () => {
title: 'First stream',
url: 'https://example.com/one.m3u8',
});
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
});
setImmediate(() => socket.emit('connect'));
return socket;
});
mockMpvSockets();
const current = await openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
@@ -305,15 +309,7 @@ describe('external player shutdown on app quit', () => {
url: 'https://example.com/one.m3u8',
});
const written: string[] = [];
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn((chunk: string) => written.push(chunk)),
end: jest.fn(),
destroy: jest.fn(),
});
setImmediate(() => socket.emit('connect'));
return socket;
});
mockMpvSockets({ written });
const reused = await openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
@@ -394,15 +390,7 @@ describe('external player shutdown on app quit', () => {
expect(launchArgs).toContain('--start=42');
const written: string[] = [];
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn((chunk: string) => written.push(chunk)),
end: jest.fn(),
destroy: jest.fn(),
});
setImmediate(() => socket.emit('connect'));
return socket;
});
mockMpvSockets({ written });
const loadfileOptionsOf = async (startTime: number | undefined) => {
written.length = 0;
await openMpvPlayer({
@@ -456,15 +444,7 @@ describe('external player shutdown on app quit', () => {
// Capture the JSON IPC traffic of the second, reused launch.
const written: string[] = [];
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn((chunk: string) => written.push(chunk)),
end: jest.fn(),
destroy: jest.fn(),
});
setImmediate(() => socket.emit('connect'));
return socket;
});
mockMpvSockets({ written });
await openMpvPlayer({
title: 'Stalker live stream',
@@ -509,15 +489,7 @@ describe('external player shutdown on app quit', () => {
url: 'https://example.com/one.m3u8',
});
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
});
setImmediate(() => socket.emit('connect'));
return socket;
});
mockMpvSockets();
const reused = await openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
@@ -560,15 +532,7 @@ describe('external player shutdown on app quit', () => {
title: 'First stream',
url: 'https://example.com/one.m3u8',
});
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
});
setImmediate(() => socket.emit('connect'));
return socket;
});
mockMpvSockets();
const reused = await openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
@@ -622,27 +586,15 @@ describe('external player shutdown on app quit', () => {
url: 'https://example.com/one.m3u8',
});
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
});
setImmediate(() => socket.emit('connect'));
return socket;
});
mockMpvSockets();
const session = await openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
});
const sockets: EventEmitter[] = [];
const sockets: MpvSocketMock[] = [];
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
});
const socket = createMpvSocketMock({ connect: false });
sockets.push(socket);
return socket;
});
@@ -676,19 +628,12 @@ describe('external player shutdown on app quit', () => {
url: 'https://example.com/one.m3u8',
});
const sockets: Array<
EventEmitter & { write: jest.Mock; end: jest.Mock }
> = [];
const sockets: MpvSocketMock[] = [];
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
const socket = createMpvSocketMock({
connect: sockets.length >= 2,
});
sockets.push(socket);
if (sockets.length > 2) {
setImmediate(() => socket.emit('connect'));
}
return socket;
});
@@ -720,7 +665,7 @@ describe('external player shutdown on app quit', () => {
expect(spawn).toHaveBeenCalledTimes(1);
});
it('does not spawn a replacement when Stop interrupts reused MPV seek', async () => {
it('does not spawn a replacement when Stop interrupts a reused MPV loadfile reply', async () => {
shutdownMpvSession();
const proc = createMockChildProcess();
(spawn as unknown as jest.Mock).mockReturnValue(proc);
@@ -733,17 +678,14 @@ describe('external player shutdown on app quit', () => {
url: 'https://example.com/one.m3u8',
});
const sockets: EventEmitter[] = [];
// The content command is written but mpv never answers it; the quit
// connection that Stop opens answers normally.
const sockets: MpvSocketMock[] = [];
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
const socket = createMpvSocketMock({
reply: sockets.length === 0 ? () => null : undefined,
});
sockets.push(socket);
if (sockets.length === 1 || sockets.length === 3) {
setImmediate(() => socket.emit('connect'));
}
return socket;
});
@@ -752,19 +694,19 @@ describe('external player shutdown on app quit', () => {
url: 'https://example.com/two.m3u8',
startTime: 120,
});
while (sockets.length < 2) {
while (!sockets[0]?.write.mock.calls.length) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
const replacementId =
externalPlayerSessions.getActiveSessionId() as string;
const closing = externalPlayerSessions.closeSession(replacementId);
while (sockets.length < 3) {
while (sockets.length < 2) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
sockets[1].emit('error', new Error('connection closed'));
sockets[0].emit('error', new Error('connection closed'));
await expect(closing).resolves.toMatchObject({
id: replacementId,
@@ -972,22 +914,11 @@ describe('external player shutdown on app quit', () => {
title: 'First stream',
url: 'https://example.com/one.m3u8',
});
let connectionCount = 0;
(createConnection as unknown as jest.Mock).mockImplementation(() => {
connectionCount += 1;
const socket = Object.assign(new EventEmitter(), {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
});
setImmediate(() => {
if (connectionCount === 1) {
socket.emit('connect');
} else {
socket.emit('error', new Error('seek failed'));
}
});
return socket;
// mpv rejects the content command after it was written, so the
// player may already have changed.
mockMpvSockets({
reply: (command) =>
command[0] === 'loadfile' ? 'error running command' : 'success',
});
jest.useFakeTimers();
@@ -1153,22 +1084,11 @@ describe('external player shutdown on app quit', () => {
title: 'First stream',
url: 'https://example.com/one.m3u8',
});
let connectionCount = 0;
(createConnection as unknown as jest.Mock).mockImplementation(() => {
connectionCount += 1;
const socket = Object.assign(new EventEmitter(), {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
});
setImmediate(() => {
if (connectionCount === 1) {
socket.emit('connect');
} else {
socket.emit('error', new Error('seek failed'));
}
});
return socket;
// mpv rejects the content command after it was written, so the
// player may already have changed.
mockMpvSockets({
reply: (command) =>
command[0] === 'loadfile' ? 'error running command' : 'success',
});
const session = await openMpvPlayer({
@@ -1,6 +1,6 @@
import { spawn } from 'child_process';
import { createConnection } from 'net';
import {
ExternalPlayerSession,
PlayerContentInfo,
readExternalPlayerErrorCode,
} from '@iptvnator/shared/interfaces';
@@ -28,15 +28,25 @@ import {
buildPlayerStartError,
externalPlayerSessions,
maskUrlForLogs,
sendPlaybackPositionUpdate,
sendPlayerErrorNotification,
traceExternalPlayer,
} from './external-player-runtime';
import { externalPlayerProcessTeardownGate } from './external-player-process';
import {
assertQueuedLaunchAllowed,
MpvLaunchQueue,
QueuedMpvLaunch,
settleCancelledQueuedLaunch,
} from './mpv-launch-queue';
import {
MpvReusableProcess,
MpvReuseAttemptState,
} from './mpv-reusable-process';
import { attachMpvOutputHandlers } from './mpv-output-errors';
import {
startPositionPolling,
stopPositionPolling,
} from './mpv-position-polling';
export interface OpenExternalPlayerRequest {
url: string;
@@ -51,11 +61,6 @@ export interface OpenExternalPlayerRequest {
}
const reusableMpvProcess = new MpvReusableProcess();
let positionPollingInterval: NodeJS.Timeout | null = null;
// The first poll waits for MPV to open its IPC socket. The handle is kept so
// a player that exits during that wait does not start an orphaned interval.
let positionPollingDelay: NodeJS.Timeout | null = null;
function getMpvPath(options: PlayerPathOptions = {}): string {
return (
normalizeCustomPlayerPath(store.get(MPV_PLAYER_PATH)) ??
@@ -63,110 +68,6 @@ function getMpvPath(options: PlayerPathOptions = {}): string {
);
}
async function getMpvProperty(
socketPath: string,
property: string
): Promise<number | null> {
return new Promise((resolve) => {
const client = createConnection(socketPath);
const request =
JSON.stringify({
command: ['get_property', property],
}) + '\n';
let data = '';
client.on('connect', () => {
client.write(request);
});
const timeoutHandle = setTimeout(() => {
client.destroy();
resolve(null);
}, 2000);
client.on('data', (chunk) => {
data += chunk.toString();
if (data.includes('\n')) {
clearTimeout(timeoutHandle);
try {
const lines = data.split('\n');
for (const line of lines) {
if (!line.trim()) continue;
const response = JSON.parse(line);
if (response.data !== undefined) {
client.destroy();
resolve(response.data);
return;
}
}
} catch {
// Ignore partial JSON while MPV is still writing.
}
}
});
client.on('end', () => {
clearTimeout(timeoutHandle);
if (data && !data.includes('\n')) {
try {
const response = JSON.parse(data);
resolve(response.data ?? null);
} catch {
resolve(null);
}
}
});
client.on('error', () => {
clearTimeout(timeoutHandle);
resolve(null);
});
});
}
function stopPositionPolling(): void {
if (positionPollingDelay) {
clearTimeout(positionPollingDelay);
positionPollingDelay = null;
}
if (positionPollingInterval) {
clearInterval(positionPollingInterval);
positionPollingInterval = null;
}
}
function shouldIgnoreMpvStdoutLine(line: string): boolean {
return /^(\(Paused\)\s*)?AV:\s/.test(line);
}
function startPositionPolling(
socketPath: string,
contentInfo: PlayerContentInfo,
sessionId: string
): void {
stopPositionPolling();
positionPollingDelay = setTimeout(() => {
positionPollingDelay = null;
positionPollingInterval = setInterval(async () => {
try {
const position = await getMpvProperty(socketPath, 'time-pos');
const duration = await getMpvProperty(socketPath, 'duration');
if (position !== null) {
sendPlaybackPositionUpdate(sessionId, contentInfo, {
positionSeconds: Math.floor(position),
durationSeconds: duration ? Math.floor(duration) : null,
});
}
} catch {
stopPositionPolling();
}
}, 5000);
}, 2000);
}
export function setMpvReuseInstance(reuseInstance: boolean): void {
traceExternalPlayer('set mpv reuse instance', { reuseInstance });
store.set(MPV_REUSE_INSTANCE, reuseInstance);
@@ -186,33 +87,71 @@ export function setMpvReuseInstance(reuseInstance: boolean): void {
* app and keeps playing after quit.
*/
export function shutdownMpvSession(): void {
mpvLaunchQueue.cancelAll();
reusableMpvProcess.stopStored(
'kill reused mpv process on app shutdown',
stopPositionPolling
);
}
export async function openMpvPlayer({
url,
title,
thumbnail,
userAgent,
referer,
origin,
contentInfo,
startTime,
headers,
}: OpenExternalPlayerRequest) {
externalPlayerProcessTeardownGate.assertLaunchAllowed();
// MPV launches run one at a time; see MpvLaunchQueue. Each launch begins its
// session at once, so the newest launch is the active one Stop targets.
const mpvLaunchQueue = new MpvLaunchQueue(() =>
reusableMpvProcess.storedOwnerSessionId()
);
export function openMpvPlayer(
request: OpenExternalPlayerRequest
): Promise<ExternalPlayerSession> {
try {
externalPlayerProcessTeardownGate.assertLaunchAllowed();
} catch (error) {
return Promise.reject(error);
}
const displacedSessionId = externalPlayerSessions.getActiveSessionId();
const previousProcessSessionId = reusableMpvProcess.currentSessionId();
const session = externalPlayerSessions.beginSession({
player: 'mpv',
title,
thumbnail,
streamUrl: url,
contentInfo,
title: request.title,
thumbnail: request.thumbnail,
streamUrl: request.url,
contentInfo: request.contentInfo,
});
return mpvLaunchQueue.enqueue(session, (queue) =>
launchMpvPlayer(request, session, displacedSessionId, queue)
);
}
async function launchMpvPlayer(
{
url,
title,
userAgent,
referer,
origin,
contentInfo,
startTime,
headers,
}: OpenExternalPlayerRequest,
session: ExternalPlayerSession,
displacedSessionId: string | null,
queue: QueuedMpvLaunch
): Promise<ExternalPlayerSession> {
const current = externalPlayerSessions.getSession(session.id);
// Stopped while it waited in the queue, directly or by a later Stop.
if (current?.status === 'closed') return current;
if (queue.queued && queue.cancelled()) {
return settleCancelledQueuedLaunch(session, queue.pendingStop());
}
if (queue.queued) {
// The last started launch owns the child the gate waits for; the
// session displaced at enqueue time is the fallback.
assertQueuedLaunchAllowed(session, [
queue.lastStartedSessionId(),
displacedSessionId,
]);
}
queue.markInFlight();
const previousProcessSessionId = reusableMpvProcess.currentSessionId();
const reuseState: MpvReuseAttemptState = {
teardownUnconfirmed: false,
contentMutated: false,
@@ -356,61 +295,7 @@ export async function openMpvPlayer({
stdio: reuseInstance ? ['ignore', 'pipe', 'pipe'] : 'ignore',
});
if (proc.stdout) {
proc.stdout.on('data', (data) => {
const lines = data
.toString()
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
for (const output of lines) {
if (shouldIgnoreMpvStdoutLine(output)) {
continue;
}
traceExternalPlayer('mpv stdout', { output });
if (
output.includes('Failed to open') ||
output.includes('Error opening') ||
output.includes('Protocol not found') ||
output.includes('Connection refused') ||
output.includes('error') ||
output.includes('403') ||
output.includes('404')
) {
console.error('[MPV ERROR from stdout]:', output);
sendPlayerErrorNotification('MPV', output);
}
}
});
}
if (proc.stderr) {
proc.stderr.on('data', (data) => {
const output = data.toString().trim();
if (output) {
console.error('[MPV stderr]:', output);
if (
output.includes('Failed to open') ||
output.includes(
'Exiting... (Errors when loading file)'
) ||
output.includes('Error opening') ||
output.includes('Protocol not found') ||
output.includes('Connection refused') ||
output.includes('error') ||
output.includes('403') ||
output.includes('404')
) {
console.error('[MPV ERROR]:', output);
sendPlayerErrorNotification('MPV', output);
}
}
});
}
attachMpvOutputHandlers(proc);
proc.on('error', (err) => {
console.error('Failed to start MPV player:', err);
@@ -0,0 +1,132 @@
/**
* Shared fixtures for the reused-MPV specs: a mock child, a reusable
* instance to hand launches over to, and IPC sockets that answer, stay
* silent, or let `quit` stop the child.
*/
import { spawn, type ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
import { createConnection } from 'net';
import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import {
MPV_PLAYER_PATH,
MPV_REUSE_INSTANCE,
store,
} from '../services/store.service';
import {
createMpvSocketMock,
MpvSocketMock,
MpvSocketMockOptions,
} from './mpv-ipc-socket.test-helpers';
import { openMpvPlayer } from './mpv-session.service';
export const spawnMock = spawn as unknown as jest.Mock;
export const createConnectionMock = createConnection as unknown as jest.Mock;
export function createMockChildProcess(): ChildProcess {
const proc = Object.assign(new EventEmitter(), {
exitCode: null,
killed: false,
kill: jest.fn(),
signalCode: null,
stderr: null,
stdout: null,
unref: jest.fn(),
}) as unknown as ChildProcess;
(proc.kill as jest.Mock).mockImplementation(() => {
setImmediate(() => {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
});
return true;
});
return proc;
}
export function mockMpvSockets(options: MpvSocketMockOptions = {}): void {
createConnectionMock.mockImplementation(() => createMpvSocketMock(options));
}
export function nextTick(): Promise<void> {
return new Promise<void>((resolve) => setImmediate(resolve));
}
/** Spawns the reusable instance a later launch is handed over to. */
export async function launchReusableInstance(): Promise<ChildProcess> {
return (await launchReusableSession()).proc;
}
export async function launchReusableSession(): Promise<{
proc: ChildProcess;
session: ExternalPlayerSession;
}> {
const proc = createMockChildProcess();
spawnMock.mockReturnValueOnce(proc);
(store.get as unknown as jest.Mock).mockImplementation(
(key: string, fallback?: unknown) =>
({
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
[MPV_REUSE_INSTANCE]: true,
})[key] ?? fallback
);
const session = await openMpvPlayer({
title: 'First stream',
url: 'https://example.com/one.m3u8',
});
return { proc, session };
}
/** A socket whose written command mpv never answers; the test replies. */
export function mockUnansweredSockets(): MpvSocketMock[] {
const sockets: MpvSocketMock[] = [];
createConnectionMock.mockImplementation(() => {
const socket = createMpvSocketMock({ reply: () => null });
sockets.push(socket);
return socket;
});
return sockets;
}
/**
* Like mockUnansweredSockets, but a `quit` succeeds and the child then exits,
* as a real mpv does.
*/
export function mockSocketsQuittingInto(proc: ChildProcess): MpvSocketMock[] {
const sockets: MpvSocketMock[] = [];
createConnectionMock.mockImplementation(() => {
const socket = createMpvSocketMock({
reply: (command) => {
if (command[0] !== 'quit') return null;
setImmediate(() => {
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
});
return 'success';
},
});
sockets.push(socket);
return socket;
});
return sockets;
}
export function commandsOf(sockets: MpvSocketMock[]): string[] {
return sockets.map(
(socket) =>
(
JSON.parse(String(socket.write.mock.calls[0][0])) as {
command: string[];
}
).command[0]
);
}
export async function writtenRequestId(socket: MpvSocketMock): Promise<number> {
while (!socket?.write.mock.calls.length) {
await nextTick();
}
return (
JSON.parse(socket.write.mock.calls[0][0] as string) as {
request_id: number;
}
).request_id;
}
+2 -1
View File
@@ -17,7 +17,8 @@
"target": "node22",
"bundle": true,
"thirdParty": true,
"generatePackageJson": true
"generatePackageJson": true,
"external": ["better-sqlite3"]
},
"inputs": [
"default",
@@ -0,0 +1,32 @@
import { createServer, type Server } from 'node:http';
import { AddressInfo } from 'node:net';
import { closeGracefully } from './graceful-shutdown';
describe('closeGracefully', () => {
let server: Server;
afterEach(() => {
server?.closeAllConnections();
});
it('stops waiting for a request that never ends after the grace period', async () => {
server = createServer((_req, res) => {
res.writeHead(200);
res.write('streaming');
// Never ends, like a provider download that keeps sending bytes.
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
const request = fetch(`http://127.0.0.1:${port}/`).catch(
() => undefined
);
await new Promise((resolve) => setTimeout(resolve, 50));
const started = Date.now();
await closeGracefully(server, 100);
expect(Date.now() - started).toBeLessThan(2_000);
expect(server.listening).toBe(false);
await request;
});
});
@@ -0,0 +1,27 @@
import type { Server } from 'node:http';
/** How long open requests may finish before their connections are cut. */
export const SHUTDOWN_GRACE_MS = 5_000;
/**
* Stops accepting connections, closes idle keep-alive sockets at once and
* cuts the remaining ones after `graceMs`: a provider download that keeps
* sending bytes must not hold the shutdown open until Docker kills it.
*/
export function closeGracefully(
server: Server,
graceMs = SHUTDOWN_GRACE_MS
): Promise<void> {
return new Promise((resolve) => {
const deadline = setTimeout(
() => server.closeAllConnections(),
graceMs
);
deadline.unref();
server.close(() => {
clearTimeout(deadline);
resolve();
});
server.closeIdleConnections();
});
}
@@ -0,0 +1,70 @@
import express from 'express';
import { withServer } from '../web-backend-app.spec-helpers';
import { createAccessGuard } from './access-guard';
function createApp() {
const app = express();
app.use(createAccessGuard('s3cret'));
app.get('/health', (_req, res) => res.json({ ok: true }));
app.get('/v1/instance', (_req, res) => res.json({ ok: true }));
app.get('/v1/playlists', (_req, res) => res.json([]));
return app;
}
describe('createAccessGuard', () => {
it('lets public routes through', () =>
withServer(createApp(), async (baseUrl) => {
expect((await fetch(`${baseUrl}/health`)).status).toBe(200);
expect((await fetch(`${baseUrl}/v1/instance`)).status).toBe(200);
}));
it('rejects a missing or wrong token with a JSON 401', () =>
withServer(createApp(), async (baseUrl) => {
const missing = await fetch(`${baseUrl}/v1/playlists`);
const wrong = await fetch(`${baseUrl}/v1/playlists`, {
headers: { authorization: 'Bearer nope' },
});
expect(missing.status).toBe(401);
await expect(missing.json()).resolves.toMatchObject({
code: 'unauthorized',
});
expect(wrong.status).toBe(401);
}));
it('accepts the right token and lets preflight through', () =>
withServer(createApp(), async (baseUrl) => {
const ok = await fetch(`${baseUrl}/v1/playlists`, {
headers: { authorization: 'Bearer s3cret' },
});
const preflight = await fetch(`${baseUrl}/v1/playlists`, {
method: 'OPTIONS',
});
expect(ok.status).toBe(200);
expect(preflight.status).not.toBe(401);
}));
it('reads the scheme case-insensitively and trims the token', () =>
withServer(createApp(), async (baseUrl) => {
const ok = await fetch(`${baseUrl}/v1/playlists`, {
headers: { authorization: 'bearer s3cret ' },
});
expect(ok.status).toBe(200);
}));
it('rejects a long whitespace header quickly', () =>
withServer(createApp(), async (baseUrl) => {
const started = Date.now();
const response = await fetch(`${baseUrl}/v1/playlists`, {
headers: {
// Under Node's 16 KB header limit, so the guard sees it.
authorization: `bearer ${' '.repeat(10_000)}x`,
},
});
expect(response.status).toBe(401);
expect(Date.now() - started).toBeLessThan(1_000);
}));
});
@@ -0,0 +1,51 @@
import { createHash, timingSafeEqual } from 'node:crypto';
import type { RequestHandler } from 'express';
/** Routes a browser must reach before it has a token. */
const PUBLIC_PATHS = new Set(['/', '/health', '/config.js', '/v1/instance']);
/**
* Optional shared-token protection for every other route, including the
* provider proxy routes. Tokens are compared as SHA-256 digests so the
* comparison is constant-time regardless of length.
*/
export function createAccessGuard(token: string): RequestHandler {
const expected = digest(token);
return (req, res, next) => {
if (req.method === 'OPTIONS' || PUBLIC_PATHS.has(req.path)) {
next();
return;
}
const token = readBearerToken(req.get('authorization'));
if (token && timingSafeEqual(digest(token), expected)) {
next();
return;
}
res.status(401).json({
message: 'This IPTVnator server requires an access token.',
status: 401,
code: 'unauthorized',
});
};
}
/**
* `Bearer <token>`, scheme case-insensitive. Plain string scanning instead
* of a regular expression keeps the cost linear in the header length.
*/
function readBearerToken(header: string | undefined): string | null {
const value = header?.trim() ?? '';
const separator = value.indexOf(' ');
if (separator < 0 || value.slice(0, separator).toLowerCase() !== 'bearer') {
return null;
}
const token = value.slice(separator + 1).trim();
return token ? token : null;
}
function digest(value: string): Buffer {
return createHash('sha256').update(value).digest();
}
@@ -0,0 +1,20 @@
import { matchesEtag } from './etag-match';
describe('matchesEtag', () => {
const etag = '"boot.0.1"';
it('matches the same strong or weakened tag', () => {
expect(matchesEtag(etag, etag)).toBe(true);
expect(matchesEtag(`W/${etag}`, etag)).toBe(true);
});
it('matches one of several listed tags and the wildcard', () => {
expect(matchesEtag(`"other", W/${etag}`, etag)).toBe(true);
expect(matchesEtag('*', etag)).toBe(true);
});
it('does not match a missing or different tag', () => {
expect(matchesEtag(undefined, etag)).toBe(false);
expect(matchesEtag('"boot.0.2"', etag)).toBe(false);
});
});
@@ -0,0 +1,22 @@
/**
* Weak comparison of an `If-None-Match` header with the current ETag, as
* RFC 9110 requires for GET. nginx weakens the ETag of a gzipped response
* (`W/"…"`), so clients send the weak form back.
*/
export function matchesEtag(
ifNoneMatch: string | undefined,
etag: string
): boolean {
if (!ifNoneMatch) {
return false;
}
const current = stripWeak(etag);
return ifNoneMatch
.split(',')
.map((tag) => tag.trim())
.some((tag) => tag === '*' || stripWeak(tag) === current);
}
function stripWeak(tag: string): string {
return tag.startsWith('W/') ? tag.slice(2) : tag;
}
@@ -0,0 +1,46 @@
import { Router } from 'express';
import {
SERVER_STORAGE_API_VERSION,
type ServerInstanceInfo,
type ServerStorageStatusResponse,
} from '@iptvnator/shared/interfaces';
import type { ServerStorage } from './server-storage';
import { StorageRequestError } from './storage-route-errors';
export function createInstanceRouter(deps: {
readonly storage: ServerStorage | null;
readonly accessTokenRequired: boolean;
}): Router {
const router = Router();
router.get('/instance', (_req, res) => {
const body: ServerInstanceInfo = {
service: 'iptvnator',
apiVersion: SERVER_STORAGE_API_VERSION,
instanceId: deps.storage?.database.instanceId ?? null,
storage: deps.storage ? 'server' : 'none',
auth: deps.accessTokenRequired ? 'token' : 'none',
};
res.set('Cache-Control', 'no-store').json(body);
});
router.get('/instance/status', (_req, res) => {
const storage = deps.storage;
if (!storage) {
throw new StorageRequestError(
404,
'storage_disabled',
'Server storage is not enabled.'
);
}
const body: ServerStorageStatusResponse = {
playlists: storage.playlists.count(),
databaseBytes: storage.database.sizeBytes(),
databasePath: storage.database.path,
startedAt: storage.startedAt.toISOString(),
};
res.set('Cache-Control', 'no-store').json(body);
});
return router;
}
@@ -0,0 +1,49 @@
import type { Express, RequestHandler } from 'express';
import { createAccessGuard } from './access-guard';
import { createInstanceRouter } from './instance-router';
import { createPlaylistsRouter } from './playlists-router';
import type { ServerStorage } from './server-storage';
import { storageErrorHandler } from './storage-route-errors';
import { createUserStateRouter } from './user-state-router';
export const DEFAULT_MAX_DOCUMENT_BYTES = 64 * 1024 * 1024;
export interface ServerStorageMountOptions {
readonly corsMiddleware: RequestHandler;
readonly storage: ServerStorage | null;
readonly accessToken: string | null;
readonly maxDocumentBytes: number;
}
/**
* Mounts `/v1` and the optional token guard. Call it before the provider
* proxy routes are registered so the guard also covers them. With a token
* the CORS headers apply to every route, so a cross-origin 401 stays
* readable for the PWA in development.
*/
export function mountServerStorage(
app: Express,
options: ServerStorageMountOptions
): void {
app.use('/v1', options.corsMiddleware);
if (options.accessToken) {
app.use(options.corsMiddleware);
app.use(createAccessGuard(options.accessToken));
}
app.use(
'/v1',
createInstanceRouter({
storage: options.storage,
accessTokenRequired: !!options.accessToken,
})
);
if (options.storage) {
app.use(
'/v1',
createPlaylistsRouter(options.storage, options.maxDocumentBytes)
);
app.use('/v1', createUserStateRouter(options.storage));
}
app.use('/v1', storageErrorHandler());
}
@@ -0,0 +1,29 @@
import { PlaylistRevisions } from './playlist-revisions';
describe('PlaylistRevisions', () => {
it('keeps an ETag until the playlist is written', () => {
const revisions = new PlaylistRevisions('boot');
expect(revisions.etag('a')).toBe('"boot.0.0"');
expect(revisions.etag('a')).toBe('"boot.0.0"');
expect(revisions.bump('a')).toBe('"boot.0.1"');
expect(revisions.etag('a')).toBe('"boot.0.1"');
expect(revisions.etag('b')).toBe('"boot.0.0"');
});
it('invalidates every ETag on reset', () => {
const revisions = new PlaylistRevisions('boot');
revisions.bump('a');
revisions.reset();
expect(revisions.etag('a')).toBe('"boot.1.0"');
expect(revisions.etag('b')).toBe('"boot.1.0"');
});
it('uses a random boot id by default', () => {
expect(new PlaylistRevisions().etag('a')).not.toBe(
new PlaylistRevisions().etag('a')
);
});
});
@@ -0,0 +1,32 @@
import { randomUUID } from 'node:crypto';
/**
* ETags for playlist documents. The server is the only writer of its
* database, so an in-memory counter per playlist is exact. The boot id
* invalidates every client cache after a restart; the generation does so
* after "delete all".
*/
export class PlaylistRevisions {
private readonly revisions = new Map<string, number>();
private generation = 0;
constructor(private readonly bootId = randomUUID().slice(0, 8)) {}
etag(playlistId: string): string {
const revision = this.revisions.get(playlistId) ?? 0;
return `"${this.bootId}.${this.generation}.${revision}"`;
}
bump(playlistId: string): string {
this.revisions.set(
playlistId,
(this.revisions.get(playlistId) ?? 0) + 1
);
return this.etag(playlistId);
}
reset(): void {
this.revisions.clear();
this.generation += 1;
}
}
@@ -0,0 +1,107 @@
import express, { Router, type Response } from 'express';
import { matchesEtag } from './etag-match';
import {
deleteAllPlaylistsWithState,
deletePlaylistWithState,
type ServerStorage,
} from './server-storage';
import { StorageRequestError } from './storage-route-errors';
export function createPlaylistsRouter(
storage: ServerStorage,
maxDocumentBytes: number
): Router {
const router = Router();
const json = express.json({ limit: maxDocumentBytes });
const { playlists, revisions } = storage;
router.get('/playlists', (req, res) => {
res.set('Cache-Control', 'no-store').json(
req.query['view'] === 'meta'
? playlists.listMetas()
: playlists.listAll()
);
});
router.get('/playlists/:id', (req, res) => {
const id = requirePlaylistId(req.params.id);
// A cheap existence check first: a tag must never confirm a
// representation that does not exist.
if (!playlists.exists(id)) {
throw new StorageRequestError(
404,
'not_found',
'Playlist not found.'
);
}
const etag = revisions.etag(id);
withEtag(res, etag);
if (matchesEtag(req.get('if-none-match'), etag)) {
res.status(304).end();
return;
}
const document = playlists.get(id);
if (!document) {
throw new StorageRequestError(
404,
'not_found',
'Playlist not found.'
);
}
res.json(document);
});
router.put('/playlists/:id', json, (req, res) => {
const id = requirePlaylistId(req.params.id);
playlists.upsert(
{ ...requireDocument(req.body), _id: id },
{ keepPayload: req.query['keepPayload'] === '1' }
);
withEtag(res, revisions.bump(id)).json({ success: true });
});
router.delete('/playlists/:id', (req, res) => {
deletePlaylistWithState(storage, requirePlaylistId(req.params.id));
res.json({ success: true });
});
router.delete('/playlists', (_req, res) => {
deleteAllPlaylistsWithState(storage);
res.json({ success: true });
});
return router;
}
function withEtag(res: Response, etag: string): Response {
return res
.set('ETag', etag)
.set('Access-Control-Expose-Headers', 'ETag')
.set('Cache-Control', 'no-cache');
}
/**
* The row codec trims ids, so an id with surrounding whitespace would be
* stored under a different key than the one its ETag tracks.
*/
function requirePlaylistId(value: string): string {
if (!value || value !== value.trim()) {
throw new StorageRequestError(
400,
'invalid_id',
'A playlist id cannot be empty or start or end with whitespace.'
);
}
return value;
}
function requireDocument(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new StorageRequestError(
400,
'invalid_body',
'Expected a playlist object.'
);
}
return value as Record<string, unknown>;
}
@@ -0,0 +1,93 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { sql } from 'drizzle-orm';
import { IPTVNATOR_DATA_DIR_ENV } from '@iptvnator/shared/database';
import { openServerDatabase } from './server-database';
import { ServerPlaylistRepository } from './server-playlist-repository';
import { m3uDocument } from './server-storage.spec-helpers';
describe('openServerDatabase', () => {
let dataDir: string;
let previous: string | undefined;
beforeEach(() => {
dataDir = mkdtempSync(join(tmpdir(), 'iptvnator-server-db-'));
previous = process.env[IPTVNATOR_DATA_DIR_ENV];
process.env[IPTVNATOR_DATA_DIR_ENV] = dataDir;
});
afterEach(() => {
if (previous === undefined) {
delete process.env[IPTVNATOR_DATA_DIR_ENV];
} else {
process.env[IPTVNATOR_DATA_DIR_ENV] = previous;
}
rmSync(dataDir, { recursive: true, force: true });
});
it('keeps the instance id across restarts', async () => {
const first = await openServerDatabase();
const instanceId = first.instanceId;
first.close();
const second = await openServerDatabase();
expect(second.instanceId).toBe(instanceId);
second.close();
});
it('still opens when the stored instance id is empty', async () => {
const first = await openServerDatabase();
new ServerPlaylistRepository(first.db).setAppState(
'server:instance-id',
''
);
first.close();
const second = await openServerDatabase();
expect(second.instanceId).toMatch(/^[0-9a-f-]{36}$/);
second.close();
});
it('runs several writes in one transaction', async () => {
const database = await openServerDatabase();
const playlists = new ServerPlaylistRepository(database.db);
expect(() =>
database.transaction(() => {
playlists.setAppState('a', '1');
throw new Error('fail after the first write');
})
).toThrow('fail after the first write');
expect(playlists.getAppState('a')).toBeNull();
database.close();
});
it('adds metadata for playlists written without it', async () => {
const first = await openServerDatabase();
new ServerPlaylistRepository(first.db).upsert(
{ ...m3uDocument('p1'), filename: 'family.m3u' },
{ keepPayload: false }
);
new ServerPlaylistRepository(first.db).upsert(m3uDocument('p2'), {
keepPayload: false,
});
// As in a database the desktop app wrote: rows, no server copy.
first.db.run(sql`DELETE FROM server_playlist_meta`);
first.db.run(
sql`UPDATE playlists SET payload = 'not json' WHERE id = 'p2'`
);
first.close();
const second = await openServerDatabase();
const metas = new ServerPlaylistRepository(second.db).listMetas();
expect(metas).toEqual(
expect.arrayContaining([
expect.objectContaining({ _id: 'p1', filename: 'family.m3u' }),
expect.objectContaining({ _id: 'p2', title: 'Family' }),
])
);
expect(metas[0]['playlist']).toBeUndefined();
second.close();
});
});
@@ -0,0 +1,82 @@
import { randomUUID } from 'node:crypto';
import { statSync } from 'node:fs';
import { eq, sql } from 'drizzle-orm';
import {
appState,
closeDatabase,
getDatabasePath,
initDatabase,
type DatabaseInstance,
} from '@iptvnator/shared/database';
import {
SERVER_PLAYLIST_META_BACKFILL_SQL,
SERVER_PLAYLIST_META_TABLE_SQL,
} from './server-playlist-meta.table';
import { SERVER_USER_STATE_TABLE_SQL } from './server-user-state.table';
const INSTANCE_ID_KEY = 'server:instance-id';
export interface ServerDatabase {
readonly db: DatabaseInstance;
readonly instanceId: string;
readonly path: string;
sizeBytes(): number;
/** Runs `work` in one SQLite transaction on the single connection. */
transaction<T>(work: () => T): T;
close(): void;
}
/**
* Opens the shared IPTVnator database (full schema and migrations, the
* same as the desktop app) and adds the server-only tables. The path comes
* from `IPTVNATOR_DATA_DIR`.
*/
export async function openServerDatabase(): Promise<ServerDatabase> {
const db = await initDatabase();
db.run(sql.raw(SERVER_USER_STATE_TABLE_SQL));
db.run(sql.raw(SERVER_PLAYLIST_META_TABLE_SQL));
db.run(sql.raw(SERVER_PLAYLIST_META_BACKFILL_SQL));
const path = getDatabasePath();
return {
db,
instanceId: ensureInstanceId(db),
path,
sizeBytes: () => fileSize(path) + fileSize(`${path}-wal`),
// better-sqlite3 runs every statement synchronously on one
// connection, so writes made inside `work` belong to this
// transaction even when they go through other repositories.
transaction: (work) => db.transaction(() => work()),
close: () => closeDatabase(),
};
}
function ensureInstanceId(db: DatabaseInstance): string {
const existing = db
.select({ value: appState.value })
.from(appState)
.where(eq(appState.key, INSTANCE_ID_KEY))
.get();
if (existing?.value) {
return existing.value;
}
const instanceId = randomUUID();
const updatedAt = new Date().toISOString();
db.insert(appState)
.values({ key: INSTANCE_ID_KEY, value: instanceId, updatedAt })
.onConflictDoUpdate({
target: appState.key,
set: { value: instanceId, updatedAt },
})
.run();
return instanceId;
}
function fileSize(path: string): number {
try {
return statSync(path).size;
} catch {
return 0;
}
}
@@ -0,0 +1,57 @@
import { SERVER_PAYLOAD_FIELDS } from '@iptvnator/shared/interfaces';
import { sql, type SQL, type SQLWrapper } from 'drizzle-orm';
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
/**
* Server-only table: each playlist document without its M3U payload
* fields, written with every upsert. Metadata reads take the JSON-only
* fields (filename, hidden groups, Stalker identity) from here, so they
* never walk the payload blobs. Created by `openServerDatabase`, never by
* the desktop app.
*/
export const SERVER_PLAYLIST_META_TABLE_SQL = `CREATE TABLE IF NOT EXISTS server_playlist_meta (
playlist_id TEXT PRIMARY KEY NOT NULL,
document TEXT
)`;
const PAYLOAD_PATHS = SERVER_PAYLOAD_FIELDS.map((field) => `'$.${field}'`).join(
', '
);
/**
* Fills the table for playlists written without it, for example a
* database copied from the desktop app. Reads each such payload once;
* later starts find nothing to do.
*/
export const SERVER_PLAYLIST_META_BACKFILL_SQL = `INSERT INTO server_playlist_meta (playlist_id, document)
SELECT id, CASE
WHEN json_valid(payload) THEN json_remove(payload, ${PAYLOAD_PATHS})
ELSE NULL
END
FROM playlists
WHERE id NOT IN (SELECT playlist_id FROM server_playlist_meta)`;
/** A stored payload without its M3U fields, or NULL when it is not JSON. */
export function payloadMetadata(payload: SQLWrapper): SQL<string | null> {
return sql<string | null>`CASE
WHEN json_valid(${payload})
THEN json_remove(${payload}, ${sql.raw(PAYLOAD_PATHS)})
ELSE NULL
END`;
}
export const serverPlaylistMeta = sqliteTable('server_playlist_meta', {
playlistId: text('playlist_id').primaryKey().notNull(),
document: text('document'),
});
/** The document as metadata reads see it: no payload fields. */
export function metadataDocument(
document: Readonly<Record<string, unknown>>
): string {
const metadata: Record<string, unknown> = { ...document };
for (const field of SERVER_PAYLOAD_FIELDS) {
delete metadata[field];
}
return JSON.stringify(metadata);
}
@@ -0,0 +1,219 @@
import { sql } from 'drizzle-orm';
import { PlaylistPayloadMissingError } from './server-playlist-repository';
import { m3uDocument, withTempStorage } from './server-storage.spec-helpers';
describe('ServerPlaylistRepository', () => {
it('stores and reads a whole playlist document', () =>
withTempStorage(({ playlists }) => {
playlists.upsert(m3uDocument('p1'), { keepPayload: false });
expect(playlists.get('p1')).toMatchObject({
_id: 'p1',
title: 'Family',
playlist: m3uDocument('p1').playlist,
});
expect(playlists.count()).toBe(1);
expect(playlists.get('missing')).toBeNull();
}));
it('lists metadata without the payload', () =>
withTempStorage(({ playlists }) => {
playlists.upsert(m3uDocument('p1'), { keepPayload: false });
const [meta] = playlists.listMetas();
expect(meta).toMatchObject({ _id: 'p1', title: 'Family' });
expect(meta['playlist']).toBeUndefined();
expect(playlists.listAll()[0]['playlist']).toEqual(
m3uDocument('p1').playlist
);
}));
it('keeps the stored payload when asked to', () =>
withTempStorage(({ playlists }) => {
playlists.upsert(m3uDocument('p1'), { keepPayload: false });
const meta: Record<string, unknown> = { ...m3uDocument('p1') };
delete meta['playlist'];
playlists.upsert(
{
...meta,
title: 'Renamed',
favorites: ['https://example.test/a'],
},
{ keepPayload: true }
);
expect(playlists.get('p1')).toMatchObject({
title: 'Renamed',
favorites: ['https://example.test/a'],
playlist: m3uDocument('p1').playlist,
});
}));
it('keeps payload-only metadata in metadata reads and round-trips', () =>
withTempStorage(({ playlists }) => {
playlists.upsert(
{
...m3uDocument('p1'),
filename: 'family.m3u',
hiddenGroupTitles: ['Adult'],
stalkerSerialNumber: 'SN-1',
},
{ keepPayload: false }
);
const [meta] = playlists.listMetas();
expect(meta).toMatchObject({
filename: 'family.m3u',
hiddenGroupTitles: ['Adult'],
stalkerSerialNumber: 'SN-1',
});
expect(meta['playlist']).toBeUndefined();
playlists.upsert(
{ ...meta, title: 'Renamed' },
{ keepPayload: true }
);
expect(playlists.get('p1')).toMatchObject({
title: 'Renamed',
filename: 'family.m3u',
hiddenGroupTitles: ['Adult'],
stalkerSerialNumber: 'SN-1',
playlist: m3uDocument('p1').playlist,
});
}));
it('serves complete metadata without reading the payload', () =>
withTempStorage(({ database, playlists }) => {
playlists.upsert(
{
...m3uDocument('p1'),
filename: 'family.m3u',
hiddenGroupTitles: ['Adult'],
},
{ keepPayload: false }
);
// A metadata read must not depend on the payload blob at all.
database.db.run(
sql`UPDATE playlists SET payload = 'not json' WHERE id = 'p1'`
);
expect(playlists.listMetas()[0]).toMatchObject({
_id: 'p1',
title: 'Family',
filename: 'family.m3u',
hiddenGroupTitles: ['Adult'],
});
}));
it('keeps a carried-over panel clock in metadata reads', () =>
withTempStorage(({ playlists }) => {
const panel = {
_id: 'x1',
title: 'Panel',
serverUrl: 'http://panel.test',
username: 'user',
password: 'secret',
importDate: '2026-10-01T00:00:00.000Z',
};
playlists.upsert(
{ ...panel, serverTimezone: 'Europe/Berlin' },
{ keepPayload: false }
);
// An older snapshot without the clock, same panel account.
playlists.upsert(
{ ...panel, title: 'Renamed' },
{ keepPayload: false }
);
const kept = { title: 'Renamed', serverTimezone: 'Europe/Berlin' };
expect(playlists.get('x1')).toMatchObject(kept);
expect(playlists.listMetas()[0]).toMatchObject(kept);
// A different account drops the clock in both reads.
playlists.upsert(
{ ...panel, password: 'changed' },
{ keepPayload: false }
);
expect(playlists.get('x1')?.['serverTimezone']).toBeUndefined();
expect(playlists.listMetas()[0]['serverTimezone']).toBeUndefined();
}));
it('drops the metadata copy with its playlist', () =>
withTempStorage(({ playlists }) => {
playlists.upsert(m3uDocument('p1'), { keepPayload: false });
playlists.upsert(m3uDocument('p2'), { keepPayload: false });
playlists.delete('p1');
expect(playlists.listMetas().map((p) => p['_id'])).toEqual(['p2']);
playlists.deleteAll();
expect(playlists.listMetas()).toEqual([]);
}));
it('clears optional columns that a replacement leaves empty', () =>
withTempStorage(({ playlists }) => {
playlists.upsert(
{
...m3uDocument('p1'),
userAgent: 'UA/1',
referrer: 'https://ref.test',
origin: 'https://origin.test',
},
{ keepPayload: false }
);
playlists.upsert(
{
...m3uDocument('p1'),
userAgent: '',
referrer: '',
},
{ keepPayload: false }
);
const stored = playlists.get('p1');
expect(stored?.['userAgent']).toBeUndefined();
expect(stored?.['referrer']).toBeUndefined();
expect(stored?.['origin']).toBeUndefined();
expect(playlists.listMetas()[0]['userAgent']).toBeUndefined();
}));
it('tells whether a playlist exists', () =>
withTempStorage(({ playlists }) => {
playlists.upsert(m3uDocument('p1'), { keepPayload: false });
expect(playlists.exists('p1')).toBe(true);
expect(playlists.exists('nope')).toBe(false);
}));
it('refuses keepPayload for an unknown playlist', () =>
withTempStorage(({ playlists }) => {
expect(() =>
playlists.upsert(
{ _id: 'nope', title: 'x' },
{ keepPayload: true }
)
).toThrow(PlaylistPayloadMissingError);
}));
it('deletes one or all playlists', () =>
withTempStorage(({ playlists }) => {
playlists.upsert(m3uDocument('p1'), { keepPayload: false });
playlists.upsert(m3uDocument('p2'), { keepPayload: false });
playlists.delete('p1');
expect(playlists.listMetas().map((p) => p['_id'])).toEqual(['p2']);
playlists.deleteAll();
expect(playlists.count()).toBe(0);
}));
it('reads and writes app state', () =>
withTempStorage(({ playlists }) => {
expect(playlists.getAppState('flag')).toBeNull();
playlists.setAppState('flag', '1');
expect(playlists.getAppState('flag')).toBe('1');
}));
});
@@ -0,0 +1,255 @@
import { eq, getTableColumns } from 'drizzle-orm';
import type { DatabaseInstance } from '@iptvnator/shared/database';
import {
buildPlaylistRow,
parseAppPlaylist,
playlistConflictUpdate,
} from '@iptvnator/shared/database/app-playlist';
import { appState, playlists } from '@iptvnator/shared/database/schema';
import type {
NewPlaylist,
Playlist as PlaylistRow,
} from '@iptvnator/shared/database/schema';
import { SERVER_PAYLOAD_FIELDS } from '@iptvnator/shared/interfaces';
import {
metadataDocument,
payloadMetadata,
serverPlaylistMeta,
} from './server-playlist-meta.table';
export type PlaylistDocument = Record<string, unknown>;
export class PlaylistPayloadMissingError extends Error {
constructor() {
super('No stored payload to keep');
this.name = 'PlaylistPayloadMissingError';
}
}
export class InvalidPlaylistDocumentError extends Error {
constructor() {
super('A playlist document needs an id');
this.name = 'InvalidPlaylistDocumentError';
}
}
/** Every column except the M3U payload, for metadata reads. */
function metadataColumns() {
const { payload, ...columns } = getTableColumns(playlists);
void payload;
return columns;
}
const metaColumns = metadataColumns();
/**
* Columns a whole-document upsert must rewrite even when the document no
* longer has a value: the codec maps empty values to `undefined`, which an
* `ON CONFLICT DO UPDATE` skips, so a cleared field would keep its old
* column value and `parseAppPlaylist` would bring it back.
*/
const REPLACEABLE_COLUMNS = [
'serverUrl',
'username',
'password',
'lastUpdated',
'userAgent',
'origin',
'referrer',
'filePath',
'epgUrls',
'detectedEpgUrls',
'manualEpgUrls',
'disabledEpgUrls',
'macAddress',
'url',
'portalUrl',
'count',
'importDate',
'updateDate',
'position',
'favorites',
'recentlyViewed',
] as const satisfies readonly (keyof NewPlaylist)[];
function clearedColumns(row: NewPlaylist): Partial<NewPlaylist> {
const cleared: Partial<Record<keyof NewPlaylist, null>> = {};
for (const column of REPLACEABLE_COLUMNS) {
if (row[column] === undefined) {
cleared[column] = null;
}
}
return cleared as Partial<NewPlaylist>;
}
/** Playlist documents in the shared `playlists` table, same rows as desktop. */
export class ServerPlaylistRepository {
constructor(private readonly db: DatabaseInstance) {}
count(): number {
return this.db.select({ id: playlists.id }).from(playlists).all()
.length;
}
/**
* Columns plus the payload-free copy of each document, so metadata
* reads keep fields that live only in the JSON (filename, hidden
* groups, Stalker identity) and a metadata round-trip with keepPayload
* loses nothing, without reading a payload blob.
*/
listMetas(): PlaylistDocument[] {
return this.db
.select({ ...metaColumns, payload: serverPlaylistMeta.document })
.from(playlists)
.leftJoin(
serverPlaylistMeta,
eq(serverPlaylistMeta.playlistId, playlists.id)
)
.all()
.map((row) => parseAppPlaylist(row as PlaylistRow));
}
exists(playlistId: string): boolean {
return !!this.db
.select({ id: playlists.id })
.from(playlists)
.where(eq(playlists.id, playlistId))
.get();
}
listAll(): PlaylistDocument[] {
return this.db
.select()
.from(playlists)
.all()
.map((row) => parseAppPlaylist(row));
}
get(playlistId: string): PlaylistDocument | null {
const row = this.db
.select()
.from(playlists)
.where(eq(playlists.id, playlistId))
.get();
return row ? parseAppPlaylist(row) : null;
}
upsert(
document: PlaylistDocument,
options: { readonly keepPayload: boolean }
): void {
const next = options.keepPayload
? this.withStoredPayload(document)
: document;
const row = buildPlaylistRow(next);
if (!row) {
throw new InvalidPlaylistDocumentError();
}
const update = {
...playlistConflictUpdate(row, next),
...clearedColumns(row),
};
this.db.transaction((tx) => {
tx.insert(playlists)
.values(row)
.onConflictDoUpdate({ target: playlists.id, set: update })
.run();
// The conflict update may carry stored fields (the panel clock)
// into the payload; the copy must then match what was saved.
const metadata =
update.payload === row.payload
? metadataDocument(next)
: (tx
.select({
document: payloadMetadata(playlists.payload),
})
.from(playlists)
.where(eq(playlists.id, row.id))
.get()?.document ?? null);
tx.insert(serverPlaylistMeta)
.values({ playlistId: row.id, document: metadata })
.onConflictDoUpdate({
target: serverPlaylistMeta.playlistId,
set: { document: metadata },
})
.run();
});
}
delete(playlistId: string): void {
this.db.transaction((tx) => {
tx.delete(playlists).where(eq(playlists.id, playlistId)).run();
tx.delete(serverPlaylistMeta)
.where(eq(serverPlaylistMeta.playlistId, playlistId))
.run();
});
}
deleteAll(): void {
this.db.transaction((tx) => {
tx.delete(playlists).run();
tx.delete(serverPlaylistMeta).run();
});
}
getAppState(key: string): string | null {
return (
this.db
.select({ value: appState.value })
.from(appState)
.where(eq(appState.key, key))
.get()?.value ?? null
);
}
setAppState(key: string, value: string): void {
const updatedAt = new Date().toISOString();
this.db
.insert(appState)
.values({ key, value, updatedAt })
.onConflictDoUpdate({
target: appState.key,
set: { value, updatedAt },
})
.run();
}
/** Fills the payload fields the client left out from the stored row. */
private withStoredPayload(document: PlaylistDocument): PlaylistDocument {
const id = String(document['_id'] ?? '');
const stored = this.db
.select({ payload: playlists.payload })
.from(playlists)
.where(eq(playlists.id, id))
.get();
const parsed = parseStoredPayload(stored?.payload);
if (!parsed) {
throw new PlaylistPayloadMissingError();
}
const merged: PlaylistDocument = { ...document };
for (const field of SERVER_PAYLOAD_FIELDS) {
if (!(field in merged) && field in parsed) {
merged[field] = parsed[field];
}
}
return merged;
}
}
function parseStoredPayload(
payload: string | null | undefined
): PlaylistDocument | null {
if (!payload) {
return null;
}
try {
const parsed: unknown = JSON.parse(payload);
return parsed && typeof parsed === 'object'
? (parsed as PlaylistDocument)
: null;
} catch {
return null;
}
}
@@ -0,0 +1,45 @@
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { IPTVNATOR_DATA_DIR_ENV } from '@iptvnator/shared/database';
import { openServerStorage, type ServerStorage } from './server-storage';
/** Real SQLite in a throwaway data directory, closed and removed afterwards. */
export async function withTempStorage<T>(
run: (storage: ServerStorage) => Promise<T> | T
): Promise<T> {
const dataDir = mkdtempSync(join(tmpdir(), 'iptvnator-server-storage-'));
const previous = process.env[IPTVNATOR_DATA_DIR_ENV];
process.env[IPTVNATOR_DATA_DIR_ENV] = dataDir;
const storage = await openServerStorage();
try {
return await run(storage);
} finally {
storage.database.close();
if (previous === undefined) {
delete process.env[IPTVNATOR_DATA_DIR_ENV];
} else {
process.env[IPTVNATOR_DATA_DIR_ENV] = previous;
}
rmSync(dataDir, { recursive: true, force: true });
}
}
export const m3uDocument = (id: string, title = 'Family') => ({
_id: id,
title,
url: `https://example.test/${id}.m3u`,
count: 1,
importDate: '2026-10-01T00:00:00.000Z',
lastUsage: '2026-10-01T00:00:00.000Z',
favorites: [],
recentlyViewed: [],
autoRefresh: false,
playlist: {
header: { raw: '#EXTM3U' },
items: [
{ id: 'a', url: 'https://example.test/a', raw: '#EXTINF:-1,A' },
],
},
});
@@ -0,0 +1,44 @@
import { PlaylistRevisions } from './playlist-revisions';
import { openServerDatabase, type ServerDatabase } from './server-database';
import { ServerPlaylistRepository } from './server-playlist-repository';
import { ServerUserStateRepository } from './server-user-state-repository';
export interface ServerStorage {
readonly database: ServerDatabase;
readonly playlists: ServerPlaylistRepository;
readonly userState: ServerUserStateRepository;
readonly revisions: PlaylistRevisions;
readonly startedAt: Date;
}
export async function openServerStorage(): Promise<ServerStorage> {
const database = await openServerDatabase();
return {
database,
playlists: new ServerPlaylistRepository(database.db),
userState: new ServerUserStateRepository(database.db),
revisions: new PlaylistRevisions(),
startedAt: new Date(),
};
}
/** Deletes a playlist and its user state together, then moves its ETag. */
export function deletePlaylistWithState(
storage: ServerStorage,
playlistId: string
): void {
storage.database.transaction(() => {
storage.playlists.delete(playlistId);
storage.userState.deleteForPlaylist(playlistId);
});
storage.revisions.bump(playlistId);
}
/** Deletes every playlist and all user state together. */
export function deleteAllPlaylistsWithState(storage: ServerStorage): void {
storage.database.transaction(() => {
storage.playlists.deleteAll();
storage.userState.deleteAll();
});
storage.revisions.reset();
}
@@ -0,0 +1,54 @@
import { withTempStorage } from './server-storage.spec-helpers';
describe('ServerUserStateRepository', () => {
it('returns every namespace, empty by default', () =>
withTempStorage(({ userState }) => {
expect(userState.all()).toEqual({
'xtream-favorites': {},
'xtream-recent-items': {},
'xtream-collection-items': {},
'xtream-playback-positions': {},
});
}));
it('stores, overwrites and deletes one entry', () =>
withTempStorage(({ userState }) => {
userState.put('xtream-favorites', 'p1', ['live:1']);
userState.put('xtream-favorites', 'p1', ['live:2']);
expect(userState.all()['xtream-favorites']).toEqual({
p1: ['live:2'],
});
userState.delete('xtream-favorites', 'p1');
expect(userState.all()['xtream-favorites']).toEqual({});
}));
it('deletes a playlist across namespaces', () =>
withTempStorage(({ userState }) => {
userState.put('xtream-favorites', 'p1', ['live:1']);
userState.put('xtream-recent-items', 'p1', []);
userState.put('xtream-favorites', 'p2', ['live:3']);
userState.deleteForPlaylist('p1');
const all = userState.all();
expect(all['xtream-favorites']).toEqual({ p2: ['live:3'] });
expect(all['xtream-recent-items']).toEqual({});
}));
it('round-trips a playlist id that names an Object.prototype key', () =>
withTempStorage(({ userState }) => {
userState.put('xtream-favorites', '__proto__', ['live:1']);
userState.put('xtream-favorites', 'constructor', ['live:2']);
const favorites = userState.all()['xtream-favorites'];
expect(Object.getOwnPropertyNames(favorites).sort()).toEqual([
'__proto__',
'constructor',
]);
expect(JSON.parse(JSON.stringify(favorites))).toEqual(
JSON.parse('{"__proto__":["live:1"],"constructor":["live:2"]}')
);
}));
});
@@ -0,0 +1,72 @@
import { and, eq } from 'drizzle-orm';
import type { DatabaseInstance } from '@iptvnator/shared/database';
import {
SERVER_USER_STATE_NAMESPACES,
type ServerUserStateNamespace,
type ServerUserStateResponse,
} from '@iptvnator/shared/interfaces';
import { serverUserState } from './server-user-state.table';
/** Xtream PWA user data, one JSON value per namespace and playlist. */
export class ServerUserStateRepository {
constructor(private readonly db: DatabaseInstance) {}
all(): ServerUserStateResponse['entries'] {
// Prototype-free buckets: a playlist id such as `__proto__` must be
// stored as an own key, not change the bucket's prototype.
const entries = Object.fromEntries(
SERVER_USER_STATE_NAMESPACES.map((namespace) => [
namespace,
Object.create(null) as Record<string, unknown>,
])
) as ServerUserStateResponse['entries'];
for (const row of this.db.select().from(serverUserState).all()) {
const bucket = entries[row.namespace as ServerUserStateNamespace];
if (bucket) {
bucket[row.playlistId] = JSON.parse(row.value);
}
}
return entries;
}
put(
namespace: ServerUserStateNamespace,
playlistId: string,
value: unknown
): void {
const serialized = JSON.stringify(value ?? null);
const updatedAt = new Date().toISOString();
this.db
.insert(serverUserState)
.values({ namespace, playlistId, value: serialized, updatedAt })
.onConflictDoUpdate({
target: [serverUserState.namespace, serverUserState.playlistId],
set: { value: serialized, updatedAt },
})
.run();
}
delete(namespace: ServerUserStateNamespace, playlistId: string): void {
this.db
.delete(serverUserState)
.where(
and(
eq(serverUserState.namespace, namespace),
eq(serverUserState.playlistId, playlistId)
)
)
.run();
}
deleteForPlaylist(playlistId: string): void {
this.db
.delete(serverUserState)
.where(eq(serverUserState.playlistId, playlistId))
.run();
}
deleteAll(): void {
this.db.delete(serverUserState).run();
}
}
@@ -0,0 +1,24 @@
import { primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core';
/**
* Server-only table: one JSON value per Xtream user-data namespace and
* playlist. Created by `openServerDatabase`, never by the desktop app.
*/
export const SERVER_USER_STATE_TABLE_SQL = `CREATE TABLE IF NOT EXISTS server_user_state (
namespace TEXT NOT NULL,
playlist_id TEXT NOT NULL,
value TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (namespace, playlist_id)
)`;
export const serverUserState = sqliteTable(
'server_user_state',
{
namespace: text('namespace').notNull(),
playlistId: text('playlist_id').notNull(),
value: text('value').notNull(),
updatedAt: text('updated_at').notNull(),
},
(table) => [primaryKey({ columns: [table.namespace, table.playlistId] })]
);
@@ -0,0 +1,45 @@
import { readServerStorageConfig } from './storage-config';
describe('readServerStorageConfig', () => {
it('keeps storage off and the API open by default', () => {
expect(readServerStorageConfig({})).toEqual({
enabled: false,
accessToken: null,
maxDocumentBytes: 64 * 1024 * 1024,
});
});
it('enables storage only for IPTVNATOR_STORAGE=server', () => {
expect(
readServerStorageConfig({ IPTVNATOR_STORAGE: ' Server ' }).enabled
).toBe(true);
expect(
readServerStorageConfig({ IPTVNATOR_STORAGE: 'none' }).enabled
).toBe(false);
expect(
readServerStorageConfig({ IPTVNATOR_STORAGE: '1' }).enabled
).toBe(false);
});
it('reads a trimmed access token and ignores a blank one', () => {
expect(
readServerStorageConfig({ IPTVNATOR_ACCESS_TOKEN: ' s3cret ' })
.accessToken
).toBe('s3cret');
expect(
readServerStorageConfig({ IPTVNATOR_ACCESS_TOKEN: ' ' })
.accessToken
).toBeNull();
});
it('reads a positive document limit in megabytes', () => {
expect(
readServerStorageConfig({ IPTVNATOR_MAX_DOCUMENT_MB: '8' })
.maxDocumentBytes
).toBe(8 * 1024 * 1024);
expect(
readServerStorageConfig({ IPTVNATOR_MAX_DOCUMENT_MB: '-1' })
.maxDocumentBytes
).toBe(64 * 1024 * 1024);
});
});
@@ -0,0 +1,28 @@
export interface ServerStorageConfig {
/** `IPTVNATOR_STORAGE=server`; the Docker image sets it. */
readonly enabled: boolean;
/** `IPTVNATOR_ACCESS_TOKEN`; guards every non-public route when set. */
readonly accessToken: string | null;
/** Largest accepted playlist document (`IPTVNATOR_MAX_DOCUMENT_MB`). */
readonly maxDocumentBytes: number;
}
const DEFAULT_MAX_DOCUMENT_MB = 64;
const BYTES_PER_MB = 1024 * 1024;
export function readServerStorageConfig(
env: Record<string, string | undefined> = process.env
): ServerStorageConfig {
const mode = env['IPTVNATOR_STORAGE']?.trim().toLowerCase();
const token = env['IPTVNATOR_ACCESS_TOKEN']?.trim();
const megabytes = Number(env['IPTVNATOR_MAX_DOCUMENT_MB']);
return {
enabled: mode === 'server',
accessToken: token ? token : null,
maxDocumentBytes:
(Number.isFinite(megabytes) && megabytes > 0
? megabytes
: DEFAULT_MAX_DOCUMENT_MB) * BYTES_PER_MB,
};
}
@@ -0,0 +1,40 @@
import {
InvalidPlaylistDocumentError,
PlaylistPayloadMissingError,
} from './server-playlist-repository';
import {
describeStorageError,
StorageRequestError,
toStorageErrorBody,
} from './storage-route-errors';
describe('toStorageErrorBody', () => {
it.each([
[
new StorageRequestError(404, 'not_found', 'Playlist not found.'),
404,
'not_found',
],
[new PlaylistPayloadMissingError(), 409, 'payload_missing'],
[new InvalidPlaylistDocumentError(), 400, 'invalid_body'],
[{ type: 'entity.too.large' }, 413, 'too_large'],
[{ type: 'entity.parse.failed' }, 400, 'invalid_json'],
[
Object.assign(new Error('busy'), { code: 'SQLITE_BUSY' }),
503,
'storage_busy',
],
[new Error('SELECT secret FROM x'), 500, 'storage_error'],
])('maps %p to %i', (error, status, code) => {
expect(toStorageErrorBody(error)).toMatchObject({ status, code });
});
it('never puts the raw error message into a 500 body or log line', () => {
const error = new Error('password=hunter2');
expect(JSON.stringify(toStorageErrorBody(error))).not.toContain(
'hunter2'
);
expect(describeStorageError(error)).toBe('Error');
});
});
@@ -0,0 +1,95 @@
import type { ErrorRequestHandler } from 'express';
import type { ServerStorageErrorBody } from '@iptvnator/shared/interfaces';
import {
InvalidPlaylistDocumentError,
PlaylistPayloadMissingError,
} from './server-playlist-repository';
export class StorageRequestError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string
) {
super(message);
this.name = 'StorageRequestError';
}
}
export function toStorageErrorBody(error: unknown): ServerStorageErrorBody {
if (error instanceof StorageRequestError) {
return {
message: error.message,
status: error.status,
code: error.code,
};
}
if (error instanceof PlaylistPayloadMissingError) {
return {
message: 'The server has no stored payload for this playlist.',
status: 409,
code: 'payload_missing',
};
}
if (error instanceof InvalidPlaylistDocumentError) {
return {
message: 'A playlist needs an id.',
status: 400,
code: 'invalid_body',
};
}
const type = (error as { type?: unknown } | null)?.type;
if (type === 'entity.too.large') {
return {
message: 'The request is larger than the server allows.',
status: 413,
code: 'too_large',
};
}
if (type === 'entity.parse.failed') {
return {
message: 'The request body is not valid JSON.',
status: 400,
code: 'invalid_json',
};
}
if ((error as { code?: unknown } | null)?.code === 'SQLITE_BUSY') {
return {
message: 'Storage is busy, try again.',
status: 503,
code: 'storage_busy',
};
}
return {
message: 'Storage request failed.',
status: 500,
code: 'storage_error',
};
}
/**
* Error name and code only: Drizzle and SQLite messages can carry bound
* values such as portal credentials, which must never reach a log.
*/
export function describeStorageError(error: unknown): string {
const name = error instanceof Error ? error.name : typeof error;
const code = (error as { code?: unknown } | null)?.code;
return typeof code === 'string' ? `${name} (${code})` : name;
}
export function storageErrorHandler(
log: (message: string) => void = console.error
): ErrorRequestHandler {
// Express recognizes error middleware by its four parameters.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
return (error, req, res, _next) => {
const body = toStorageErrorBody(error);
if (body.status >= 500) {
const route = `${req.baseUrl}${req.route?.path ?? ''}`;
log(
`[web-backend] storage ${req.method} ${route} failed: ${describeStorageError(error)}`
);
}
res.status(body.status).json(body);
};
}
@@ -0,0 +1,312 @@
import { createWebBackendApp } from '../web-backend-app';
import {
resolvePublicHost,
StubHttpClient,
withServer,
} from '../web-backend-app.spec-helpers';
import { m3uDocument, withTempStorage } from './server-storage.spec-helpers';
import type {
ServerInstanceInfo,
ServerStorageStatusResponse,
ServerUserStateResponse,
} from '@iptvnator/shared/interfaces';
import type { ServerStorage } from './server-storage';
function app(
storage: ServerStorage | null,
extra: { accessToken?: string; maxDocumentBytes?: number } = {}
) {
return createWebBackendApp({
clientOrigins: ['http://localhost:4200'],
httpClient: new StubHttpClient(),
resolveHostname: resolvePublicHost,
storage,
...extra,
});
}
const json = (body: unknown, init: RequestInit = {}): RequestInit => ({
...init,
body: JSON.stringify(body),
headers: {
'content-type': 'application/json',
...((init.headers as Record<string, string> | undefined) ?? {}),
},
});
describe('server storage routes', () => {
it('reports no storage when it is disabled', () =>
withServer(app(null), async (baseUrl) => {
const response = await fetch(`${baseUrl}/v1/instance`);
await expect(response.json()).resolves.toEqual({
service: 'iptvnator',
apiVersion: 1,
instanceId: null,
storage: 'none',
auth: 'none',
});
expect((await fetch(`${baseUrl}/v1/playlists`)).status).toBe(404);
}));
it('reports server storage and its status', () =>
withTempStorage((storage) =>
withServer(app(storage), async (baseUrl) => {
const instance = await (
await fetch(`${baseUrl}/v1/instance`)
).json();
expect(instance).toMatchObject({
storage: 'server',
instanceId: storage.database.instanceId,
});
const status = (await (
await fetch(`${baseUrl}/v1/instance/status`)
).json()) as ServerStorageStatusResponse;
expect(status).toMatchObject({ playlists: 0 });
expect(status.databaseBytes).toBeGreaterThan(0);
})
));
it('stores a document, serves it with an ETag and answers 304', () =>
withTempStorage((storage) =>
withServer(app(storage), async (baseUrl) => {
const put = await fetch(
`${baseUrl}/v1/playlists/p1`,
json(m3uDocument('p1'), { method: 'PUT' })
);
expect(put.status).toBe(200);
const etag = put.headers.get('etag');
expect(etag).toBeTruthy();
const get = await fetch(`${baseUrl}/v1/playlists/p1`);
expect(get.headers.get('etag')).toBe(etag);
await expect(get.json()).resolves.toMatchObject({ _id: 'p1' });
const cached = await fetch(`${baseUrl}/v1/playlists/p1`, {
headers: { 'if-none-match': etag ?? '' },
});
expect(cached.status).toBe(304);
// nginx weakens the ETag of a gzipped response.
const weakened = await fetch(`${baseUrl}/v1/playlists/p1`, {
headers: { 'if-none-match': `W/${etag}` },
});
expect(weakened.status).toBe(304);
})
));
it('answers 404, not 304, for a missing playlist', () =>
withTempStorage((storage) =>
withServer(app(storage), async (baseUrl) => {
const wildcard = await fetch(`${baseUrl}/v1/playlists/nope`, {
headers: { 'if-none-match': '*' },
});
const guessed = await fetch(`${baseUrl}/v1/playlists/nope`, {
headers: {
'if-none-match': storage.revisions.etag('nope'),
},
});
expect(wildcard.status).toBe(404);
expect(guessed.status).toBe(404);
})
));
it('keeps the payload on keepPayload and refuses it for unknown ids', () =>
withTempStorage((storage) =>
withServer(app(storage), async (baseUrl) => {
await fetch(
`${baseUrl}/v1/playlists/p1`,
json(m3uDocument('p1'), { method: 'PUT' })
);
const meta: Record<string, unknown> = { ...m3uDocument('p1') };
delete meta['playlist'];
const keep = await fetch(
`${baseUrl}/v1/playlists/p1?keepPayload=1`,
json({ ...meta, title: 'Renamed' }, { method: 'PUT' })
);
const missing = await fetch(
`${baseUrl}/v1/playlists/zz?keepPayload=1`,
json({ title: 'x' }, { method: 'PUT' })
);
expect(keep.status).toBe(200);
await expect(
(await fetch(`${baseUrl}/v1/playlists/p1`)).json()
).resolves.toMatchObject({
title: 'Renamed',
playlist: m3uDocument('p1').playlist,
});
expect(missing.status).toBe(409);
await expect(missing.json()).resolves.toMatchObject({
code: 'payload_missing',
});
})
));
it('lists metadata and deletes documents with their user state', () =>
withTempStorage((storage) =>
withServer(app(storage), async (baseUrl) => {
await fetch(
`${baseUrl}/v1/playlists/p1`,
json(m3uDocument('p1'), { method: 'PUT' })
);
await fetch(
`${baseUrl}/v1/user-state/xtream-favorites/p1`,
json({ value: ['live:1'] }, { method: 'PUT' })
);
const metas = (await (
await fetch(`${baseUrl}/v1/playlists?view=meta`)
).json()) as Record<string, unknown>[];
expect(metas[0]['playlist']).toBeUndefined();
await fetch(`${baseUrl}/v1/playlists/p1`, {
method: 'DELETE',
});
expect((await fetch(`${baseUrl}/v1/playlists/p1`)).status).toBe(
404
);
const state = (await (
await fetch(`${baseUrl}/v1/user-state`)
).json()) as ServerUserStateResponse;
expect(state.entries['xtream-favorites']).toEqual({});
})
));
it('validates user-state namespaces and stores app state', () =>
withTempStorage((storage) =>
withServer(app(storage), async (baseUrl) => {
const bad = await fetch(
`${baseUrl}/v1/user-state/settings/p1`,
json({ value: 1 }, { method: 'PUT' })
);
expect(bad.status).toBe(400);
await fetch(
`${baseUrl}/v1/app-state/flag`,
json({ value: '1' }, { method: 'PUT' })
);
const read = await fetch(`${baseUrl}/v1/app-state/flag`);
expect(read.headers.get('cache-control')).toBe('no-store');
await expect(read.json()).resolves.toEqual({ value: '1' });
})
));
it('keeps browser app state away from server-owned keys', () =>
withTempStorage((storage) =>
withServer(app(storage), async (baseUrl) => {
await fetch(
`${baseUrl}/v1/app-state/server:instance-id`,
json({ value: '' }, { method: 'PUT' })
);
expect(
storage.playlists.getAppState('server:instance-id')
).toBe(storage.database.instanceId);
await expect(
(
await fetch(
`${baseUrl}/v1/app-state/server:instance-id`
)
).json()
).resolves.toEqual({ value: '' });
})
));
it('keeps the playlist when its user-state cleanup fails', () =>
withTempStorage((storage) =>
withServer(app(storage), async (baseUrl) => {
await fetch(
`${baseUrl}/v1/playlists/p1`,
json(m3uDocument('p1'), { method: 'PUT' })
);
const etag = storage.revisions.etag('p1');
jest.spyOn(
storage.userState,
'deleteForPlaylist'
).mockImplementationOnce(() => {
throw new Error('disk full');
});
const response = await fetch(`${baseUrl}/v1/playlists/p1`, {
method: 'DELETE',
});
expect(response.status).toBe(500);
expect(storage.playlists.exists('p1')).toBe(true);
expect(storage.revisions.etag('p1')).toBe(etag);
})
));
it('rejects playlist ids with surrounding whitespace', () =>
withTempStorage((storage) =>
withServer(app(storage), async (baseUrl) => {
const put = await fetch(
`${baseUrl}/v1/playlists/%20p1%20`,
json(m3uDocument('p1'), { method: 'PUT' })
);
const get = await fetch(`${baseUrl}/v1/playlists/%20p1%20`);
expect(put.status).toBe(400);
await expect(put.json()).resolves.toMatchObject({
code: 'invalid_id',
});
expect(get.status).toBe(400);
expect(storage.playlists.count()).toBe(0);
})
));
it('answers 413 above the document limit', () =>
withTempStorage((storage) =>
withServer(
app(storage, { maxDocumentBytes: 512 }),
async (baseUrl) => {
const response = await fetch(
`${baseUrl}/v1/playlists/p1`,
json(
{ ...m3uDocument('p1'), padding: 'x'.repeat(2048) },
{ method: 'PUT' }
)
);
expect(response.status).toBe(413);
}
)
));
it('requires the token for storage and proxy routes when one is set', () =>
withTempStorage((storage) =>
withServer(
app(storage, { accessToken: 's3cret' }),
async (baseUrl) => {
const instance = (await (
await fetch(`${baseUrl}/v1/instance`)
).json()) as ServerInstanceInfo;
expect(instance.auth).toBe('token');
expect(
(await fetch(`${baseUrl}/v1/playlists`)).status
).toBe(401);
expect(
(
await fetch(
`${baseUrl}/provider-targets`,
json(
{ url: 'http://example.test' },
{ method: 'POST' }
)
)
).status
).toBe(401);
expect(
(
await fetch(`${baseUrl}/v1/playlists`, {
headers: { authorization: 'Bearer s3cret' },
})
).status
).toBe(200);
}
)
));
});
@@ -0,0 +1,87 @@
import express, { Router } from 'express';
import {
isServerUserStateNamespace,
type ServerUserStateNamespace,
} from '@iptvnator/shared/interfaces';
import type { ServerStorage } from './server-storage';
import { StorageRequestError } from './storage-route-errors';
const USER_STATE_BODY_LIMIT = '8mb';
/**
* Browser app state lives under its own prefix in `app_state`, so a client
* key can never overwrite a server-owned one such as the instance id.
*/
const CLIENT_APP_STATE_PREFIX = 'client:';
export function createUserStateRouter(storage: ServerStorage): Router {
const router = Router();
const json = express.json({ limit: USER_STATE_BODY_LIMIT });
router.get('/user-state', (_req, res) => {
res.set('Cache-Control', 'no-store').json({
entries: storage.userState.all(),
});
});
router.put('/user-state/:namespace/:playlistId', json, (req, res) => {
const body = req.body as { value?: unknown } | undefined;
if (!body || typeof body !== 'object' || !('value' in body)) {
throw new StorageRequestError(
400,
'invalid_body',
'Expected { value }.'
);
}
storage.userState.put(
requireNamespace(req.params.namespace),
req.params.playlistId,
body.value
);
res.json({ success: true });
});
router.delete('/user-state/:namespace/:playlistId', (req, res) => {
storage.userState.delete(
requireNamespace(req.params.namespace),
req.params.playlistId
);
res.json({ success: true });
});
router.get('/app-state/:key', (req, res) => {
res.set('Cache-Control', 'no-store').json({
value: storage.playlists.getAppState(
CLIENT_APP_STATE_PREFIX + req.params.key
),
});
});
router.put('/app-state/:key', json, (req, res) => {
const value = (req.body as { value?: unknown } | undefined)?.value;
if (typeof value !== 'string') {
throw new StorageRequestError(
400,
'invalid_body',
'Expected { value: string }.'
);
}
storage.playlists.setAppState(
CLIENT_APP_STATE_PREFIX + req.params.key,
value
);
res.json({ success: true });
});
return router;
}
function requireNamespace(value: string): ServerUserStateNamespace {
if (!isServerUserStateNamespace(value)) {
throw new StorageRequestError(
400,
'invalid_namespace',
'Unknown user-state namespace.'
);
}
return value;
}
@@ -43,6 +43,11 @@ import {
resolveHostname,
validateProviderUrl,
} from './provider-url-policy';
import {
DEFAULT_MAX_DOCUMENT_BYTES,
mountServerStorage,
} from './storage/mount-server-storage';
import type { ServerStorage } from './storage/server-storage';
export type {
WebBackendHttpClient,
WebBackendHttpGetOptions,
@@ -67,6 +72,12 @@ export interface WebBackendAppOptions {
readonly now?: () => Date;
readonly resolveHostname?: (hostname: string) => Promise<readonly string[]>;
readonly runtimeBackendUrl?: string;
/** Optional bearer token for every non-public route. */
readonly accessToken?: string | null;
/** Largest accepted playlist document in bytes. */
readonly maxDocumentBytes?: number;
/** Server storage; `null` or absent keeps the backend stateless. */
readonly storage?: ServerStorage | null;
}
type ProviderTargetRegistry = Map<string, URL>;
@@ -129,6 +140,14 @@ export function createWebBackendApp(
);
});
mountServerStorage(app, {
corsMiddleware,
storage: options.storage ?? null,
accessToken: options.accessToken ?? null,
maxDocumentBytes:
options.maxDocumentBytes ?? DEFAULT_MAX_DOCUMENT_BYTES,
});
app.options('/provider-targets', corsMiddleware);
app.post(
'/provider-targets',
+52 -11
View File
@@ -2,18 +2,59 @@ import {
applyDefaultAutoSelectFamilyAttemptTimeout,
AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG,
} from '@iptvnator/shared/host-health';
import { closeGracefully } from './app/graceful-shutdown';
import { createWebBackendApp } from './app/web-backend-app';
import { readServerStorageConfig } from './app/storage/storage-config';
import {
openServerStorage,
type ServerStorage,
} from './app/storage/server-storage';
import { describeStorageError } from './app/storage/storage-route-errors';
const attemptTimeout = applyDefaultAutoSelectFamilyAttemptTimeout();
if (attemptTimeout !== null) {
console.log(
`[web-backend] connection attempt timeout set to ${attemptTimeout} ms for IPv6->IPv4 fallback; pass ${AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG} via NODE_OPTIONS to override`
);
async function start(): Promise<void> {
const attemptTimeout = applyDefaultAutoSelectFamilyAttemptTimeout();
if (attemptTimeout !== null) {
console.log(
`[web-backend] connection attempt timeout set to ${attemptTimeout} ms for IPv6->IPv4 fallback; pass ${AUTO_SELECT_FAMILY_ATTEMPT_TIMEOUT_FLAG} via NODE_OPTIONS to override`
);
}
const config = readServerStorageConfig();
let storage: ServerStorage | null = null;
if (config.enabled) {
try {
storage = await openServerStorage();
console.log(
`[web-backend] server storage at ${storage.database.path}`
);
} catch (error) {
console.error(
`[web-backend] server storage could not open: ${describeStorageError(error)}. Check that IPTVNATOR_DATA_DIR is a writable volume.`
);
process.exit(1);
}
}
const port = Number(process.env['PORT'] ?? 3000);
const app = createWebBackendApp({
storage,
accessToken: config.accessToken,
maxDocumentBytes: config.maxDocumentBytes,
});
const server = app.listen(port, () => {
console.log(
`IPTVnator web backend listening on http://localhost:${port}`
);
});
const shutdown = () => {
void closeGracefully(server).then(() => {
storage?.database.close();
process.exit(0);
});
};
process.once('SIGTERM', shutdown);
process.once('SIGINT', shutdown);
}
const port = Number(process.env['PORT'] ?? 3000);
const app = createWebBackendApp();
app.listen(port, () => {
console.log(`IPTVnator web backend listening on http://localhost:${port}`);
});
void start();
@@ -62,6 +62,49 @@ describe('ExternalPlaybackService', () => {
expect(service.visibleSession()).toEqual(next);
});
it('keeps the newest launch when an older launch republishes as launching', () => {
const older = createSession({
id: 'session-older',
startedAt: '2026-03-07T10:00:00.000Z',
});
const newer = createSession({
id: 'session-newer',
title: 'Newer Example',
startedAt: '2026-03-07T10:00:01.000Z',
});
listener?.(older);
listener?.(newer);
// The older launch is still settling and republishes itself.
listener?.({
...older,
canClose: true,
updatedAt: '2026-03-07T10:00:02.000Z',
});
expect(service.activeSession()?.id).toBe('session-newer');
});
it('orders launches made in the same millisecond by launch order', () => {
const older = createSession({ id: 'session-older', launchOrder: 1 });
const newer = createSession({
id: 'session-newer',
title: 'Newer Example',
launchOrder: 2,
});
listener?.(older);
listener?.(newer);
// Same startedAt: only the launch order tells the two apart.
listener?.({
...older,
canClose: true,
updatedAt: '2026-03-07T10:00:02.000Z',
});
expect(service.activeSession()?.id).toBe('session-newer');
});
it('matches only active non-error sessions to content info', () => {
const playing = createSession({
status: 'playing',
@@ -4,6 +4,17 @@ import {
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
/** Launch order when both carry it, else the start time. */
function startedBefore(
session: ExternalPlayerSession,
other: ExternalPlayerSession
): boolean {
if (session.launchOrder !== undefined && other.launchOrder !== undefined) {
return session.launchOrder < other.launchOrder;
}
return Date.parse(session.startedAt) < Date.parse(other.startedAt);
}
@Injectable({
providedIn: 'root',
})
@@ -105,17 +116,25 @@ export class ExternalPlaybackService {
const current = this.activeSession();
const restoresCurrentReplacement =
current?.id === session.restoredFromSessionId;
// A launching update means a new launch only when it did not start
// before the current one: an older launch still settling (MPV runs
// launches one at a time) must not take the dock back from it.
const isLatestLaunch =
session.status === 'launching' &&
(!current ||
current.id === session.id ||
!startedBefore(session, current));
if (
!current ||
current.id === session.id ||
session.status === 'launching' ||
isLatestLaunch ||
restoresCurrentReplacement
) {
this.activeSession.set(session);
}
if (session.status === 'launching' || restoresCurrentReplacement) {
if (isLatestLaunch || restoresCurrentReplacement) {
this.dismissedSessionId.set(null);
}
}
+22 -1
View File
@@ -28,6 +28,22 @@ RUN node tools/build/inject-build-commit.mjs
RUN pnpm nx build web --configuration=pwa
RUN pnpm nx build web-backend
# better-sqlite3 is a native addon. This stage is NOT pinned to
# $BUILDPLATFORM, so multi-arch builds produce it for each target platform.
# prebuild-install downloads a musl binary when one exists and otherwise
# compiles with the toolchain below.
FROM node:24-alpine AS native
RUN apk add --no-cache python3 make g++
WORKDIR /opt/native
COPY package.json ./root-package.json
RUN BETTER_SQLITE3_VERSION="$(node -p 'require("./root-package.json").dependencies["better-sqlite3"]')" \
&& npm init -y >/dev/null \
&& npm install --omit=dev --no-audit --no-fund "better-sqlite3@${BETTER_SQLITE3_VERSION}"
FROM node:24-alpine
RUN apk add --no-cache gettext nginx
@@ -37,13 +53,18 @@ WORKDIR /opt/iptvnator
ENV PORT=3000
ENV BACKEND_URL=/api
ENV CLIENT_URL=http://localhost:4333
ENV IPTVNATOR_STORAGE=server
ENV IPTVNATOR_DATA_DIR=/data
COPY --from=build /usr/src/app/dist/apps/web /usr/share/nginx/html
COPY --from=build /usr/src/app/dist/apps/web-backend ./web-backend
COPY --from=native /opt/native/node_modules ./web-backend/node_modules
COPY docker/nginx.conf /etc/nginx/http.d/default.conf.template
COPY docker/docker-entrypoint.sh /usr/local/bin/iptvnator-entrypoint
RUN chmod +x /usr/local/bin/iptvnator-entrypoint
RUN chmod +x /usr/local/bin/iptvnator-entrypoint && mkdir -p /data
VOLUME ["/data"]
EXPOSE 80
+21
View File
@@ -98,6 +98,11 @@ safe local defaults shown below.
| `PORT` | `3000` | Internal Express backend port. nginx proxy config is rendered from the template to match it at startup. |
| `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS` | `0` | Set to `1` or `true` only for trusted local/LAN deployments that intentionally proxy private network IPTV or mock endpoints. |
| `NODE_EXTRA_CA_CERTS` | unset | Optional Node.js CA bundle path for providers using private certificate authorities. Mount the CA file into the container and set this path. |
| `IPTVNATOR_STORAGE` | `server` in the image | `server` opens the server storage database under `IPTVNATOR_DATA_DIR` and its `/api/v1` routes; `none` keeps the backend stateless. |
| `IPTVNATOR_DATA_DIR` | `/data` in the image | Data root; the database is `<root>/databases/iptvnator.db`. Mount a volume here. |
| `IPTVNATOR_ACCESS_TOKEN` | unset | When set, every route except `/`, `/health`, `/config.js` and `/v1/instance` requires `Authorization: Bearer <token>`, proxy routes included. The browser app cannot send a token yet: leave it unset until it can. |
| `IPTVNATOR_MAX_DOCUMENT_MB` | `64` | Largest playlist document the backend accepts. Keep the nginx `client_max_body_size` in step. |
| `IPTVNATOR_BACKEND_START_TIMEOUT` | `300` | Seconds the entrypoint waits for the backend's `/health` before giving up. Database migrations run before the backend listens. |
The web backend proxy accepts only `http` and `https` provider URLs. The PWA
first registers provider URLs through `/provider-targets`, then uses the
@@ -127,6 +132,22 @@ define a health check against `/api/health`. If nginx or the backend exits
after startup, the entrypoint exits the container so Docker Compose can apply
the `restart: unless-stopped` policy.
## Server Storage
The image opens a server storage database on the `/data` volume
(`IPTVNATOR_STORAGE=server`, `IPTVNATOR_DATA_DIR=/data`) and serves its API
under `/api/v1`. It is the backend half of keeping playlists, favorites,
history and playback positions on the server for every device; the PWA
starts using it in a follow-up change. Until then the volume holds an empty
database and each browser keeps its own data.
Provider credentials will be stored unencrypted in that database, as the
desktop app does. Do not expose an instance with server storage to the
internet without authentication in your reverse proxy. The backend already
supports `IPTVNATOR_ACCESS_TOKEN`, but the browser app cannot send the
token yet, so setting it now makes the app unusable. Back up the volume like any database: stop the container, or copy
`databases/iptvnator.db` together with its `-wal` file.
## IPv6, VPNs, And Connection Fallback
Node races IPv6 and IPv4 addresses when a provider hostname has both ("happy
+8
View File
@@ -13,9 +13,17 @@ services:
CLIENT_URL: http://localhost:4333
PORT: "3000"
IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS: "0"
# Server storage database on the /data volume. Set to "none" to keep
# the backend stateless.
IPTVNATOR_STORAGE: server
volumes:
- iptvnator-data:/data
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/api/health >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes:
iptvnator-data: {}
+6 -2
View File
@@ -60,8 +60,12 @@ trap handle_signal INT TERM
trap cleanup EXIT
backend_ready=0
# With server storage the backend migrates its database before it listens,
# which can take a while on a large volume. The loop still exits at once if
# the backend process dies.
BACKEND_START_TIMEOUT="${IPTVNATOR_BACKEND_START_TIMEOUT:-300}"
for _ in $(seq 1 30); do
for _ in $(seq 1 "$BACKEND_START_TIMEOUT"); do
if wget -qO- "http://127.0.0.1:${PORT}/health" >/dev/null 2>&1; then
backend_ready=1
break
@@ -79,7 +83,7 @@ for _ in $(seq 1 30); do
done
if [ "$backend_ready" -ne 1 ]; then
echo "IPTVnator web backend did not become healthy on port ${PORT}."
echo "IPTVnator web backend did not become healthy on port ${PORT} within ${BACKEND_START_TIMEOUT} seconds (IPTVNATOR_BACKEND_START_TIMEOUT)."
exit 1
fi
+8
View File
@@ -13,6 +13,14 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Playlist documents carry whole M3U payloads (default backend
# limit IPTVNATOR_MAX_DOCUMENT_MB=64).
client_max_body_size 64m;
proxy_read_timeout 120s;
gzip on;
gzip_proxied any;
gzip_types application/json;
gzip_min_length 1024;
}
location /assets/app-config.js {
+62 -2
View File
@@ -1029,6 +1029,61 @@ is also idempotent: it returns the closed snapshot without invoking the saved
closer, and a later process error cannot revive it as a visible failure. Reused
MPV commands use the socket captured for their exact child, so a delayed close
cannot send `quit` to a replacement process through a newer global socket.
Every reused MPV command carries a `request_id` and waits, within the same 2 s
IPC bound, for mpv's matching newline-delimited reply; event lines and replies
to other requests on that connection are skipped. An error reply or a missing
reply fails the handoff exactly like a socket error: the reused child is torn
down and a fresh launch follows. `loadfile` marks the content as possibly
changed and moves ownership of the child to the attempted session from the
moment it is written, whatever mpv replies, and stops the previous session's
position poll there, including a read already awaiting mpv, so an exit or a
stale Stop for the previous session during
the reply wait settles the right session and no poll can save the new stream's
position under the old content; a child that exited while its reply was in
flight is returned as the session its exit handler settled, never reported as
opened and never replaced by a fresh launch under that terminal session.
MPV launches run one at a time. A reused launch waits for mpv's replies and a
failed one for its teardown and fallback spawn, so a launch that arrives
meanwhile, such as a quick channel switch, starts only once the previous one
has settled and acts on the player that one left behind; two launches never
act on the same child. Each launch begins its session at once, so the newest
launch is the active session; the renderer lets a `launching` update take the
dock only when that launch did not start before the current one, by the
session's `launchOrder` (which separates launches in the same millisecond) or
else its `startedAt`, so an older launch still settling cannot pull the dock
back. Only the newest queued launch
runs: an older
one that comes up while a newer one waits behind it settles closed without
reaching mpv or taking the dock. Stop on a launch still waiting in the queue
cancels it and every launch queued before it; on the newest queued launch it
also closes, through their own closers, the in-flight launch and the session
owning the stored player at that moment (read from the per-child mapping, which
a failed attempt hands back to the previous owner during its teardown), so
their exact-child, retry and settle rules apply. Because launches run one at a
time, nothing else acts on a reused child while an attempt is in flight, so
that attempt's own Stop always reaches its child until the attempt settles,
whatever the per-child mapping names (the previous owner before `loadfile` or
during a failed attempt's teardown, possibly closed already); after it settles,
ownership decides as before, except that a Stop of that attempt which could
not confirm the exit keeps the right to retry that exact child. These rules apply
and nothing starts playing after Stop. Those targets are fixed at the first
Stop, so a retried Stop never reaches a player started later, and a late Stop
for an older queued launch cancels only that launch. The cancelled launch
settles only once its own Stop has: if the exit cannot be confirmed it stays a
closable error, so Stop can be retried. App shutdown cancels the whole queue.
A queued launch that finds the teardown gate closed, because the launch before
it could not confirm its child's exit, restores the session that still owns
that child, preferring the last launch that started (with reuse off its
detached child is not stored anywhere else); if that is not possible, it stays
a closable error so Stop can still tear the child down. Stopping the stored player, when reuse is disabled or
the app quits, cancels an in-flight reuse attempt: it dispatches no further
commands, never falls back to a fresh reusable player, also after waiting for
a failed child's exit, and settles its own session as closed once the child's
exit is confirmed, or as a closable error when it is not. No `seek` follows
`loadfile`: mpv rejects a seek before the file is loaded, so the resume offset
travels as the per-file `start` option. The `loadfile` reply only acknowledges
the command; a stream that later fails to open is reported through the reused
child's output and exit, not through the reply.
If Stop is observed before a pending MPV content command or VLC enqueue command
is dispatched, that command is skipped. A source handoff also fails closed
while a live session has no closer (`canClose: false`); renderer Dismiss is not
@@ -1142,8 +1197,13 @@ External MPV/VLC integration is split across focused main-process modules:
session tracking, trace logging, renderer notifications, playback-position
forwarding, and user-facing start errors.
- `apps/electron-backend/src/app/events/mpv-session.service.ts` owns fresh MPV
launches and progress polling; `mpv-reusable-process.ts` owns the tracked
child, captured socket, remapping, retryable close, and reuse handoff.
launches; `mpv-position-polling.ts` owns progress polling over the IPC socket
and `mpv-output-errors.ts` the child's stdout/stderr error reporting;
`mpv-reusable-process.ts` owns the tracked
child, captured socket, remapping, retryable close, and reuse handoff;
`mpv-ipc-command.ts` owns the bounded request/reply IPC command the reuse
handoff and its quit use; `mpv-launch-queue.ts` runs MPV launches one at a
time and owns queued-launch cancellation and Stop.
- `apps/electron-backend/src/app/events/vlc-session.service.ts` owns fresh VLC
launches and progress polling; `vlc-reusable-process.ts` owns the tracked
child, RC-port remapping, retryable close, and reuse handoff, while
+1 -1
View File
@@ -210,7 +210,7 @@ user who leaves that screen open pays them indefinitely, minimized included.
| Source | What it does | Period | Cost per firing | Justified | Evidence | Follow-up |
| --- | --- | --- | --- | --- | --- | --- |
| [mpv-session.service.ts:140–141](../../apps/electron-backend/src/app/events/mpv-session.service.ts) | External MPV position poll | 2 s delay, then 5 s | Two IPC-socket round-trips and one `playback-position-update` | Yes while playing. The early-exit leak found here (an unstored start-delay handle leaving an orphaned 5 s poll) is **resolved by #1720**. Still open: in reuse mode the poll keeps querying an idle MPV (no IPC while `time-pos` is null). | Static | Early-exit leak: done (#1720). Reuse mode: needs an idle signal from MPV before the poll can stop; not worth its own thread at two local socket calls per 5 s. |
| [mpv-position-polling.ts](../../apps/electron-backend/src/app/events/mpv-position-polling.ts) | External MPV position poll | 2 s delay, then 5 s | Two IPC-socket round-trips and one `playback-position-update` | Yes while playing. The early-exit leak found here (an unstored start-delay handle leaving an orphaned 5 s poll) is **resolved by #1720**. Still open: in reuse mode the poll keeps querying an idle MPV (no IPC while `time-pos` is null). | Static | Early-exit leak: done (#1720). Reuse mode: needs an idle signal from MPV before the poll can stop; not worth its own thread at two local socket calls per 5 s. |
| [vlc-session.service.ts:81–82](../../apps/electron-backend/src/app/events/vlc-session.service.ts) | External VLC position poll | 1.5 s delay, then 2 s | Up to three localhost TCP connections, then IPC | Yes while playing. The early-exit leak (orphaned 2 s poll) is **resolved by #1720**. | Static | Done (#1720). |
| [embedded-mpv-native.service.ts:1068](../../apps/electron-backend/src/app/services/embedded-mpv-native.service.ts) | Embedded MPV session snapshot poll | 500 ms while a session exists | Native snapshot, diff, and IPC only on change | Yes during playback. It is cleared when the last session closes. | Static | None |
| [embedded-mpv-session-controller.ts:197](../../libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-session-controller.ts) | Renderer-side bounds poll for a native-view embedded MPV session: compares `getBoundingClientRect()` with the last synced bounds | 500 ms while a native-view session is open (skipped for frame-copy) | One layout read outside the Angular zone; on drift a rAF and a `setEmbeddedMpvBounds` IPC | Yes during playback: a position-only layout shift would otherwise leave the native view misplaced | Static | Cover with the throttling thread: it kept its rate while minimized under `backgroundThrottling: false`. |
+18 -3
View File
@@ -1053,9 +1053,24 @@ was unchanged from passing launches (the same 119 statements before the
first key, the usual 2 for the query, none afterwards), and every failure
traced was the first launch after seeding. The journey fails such an
iteration instead of measuring it, so a full run occasionally fails J4.
The cause is in the app, not the harness, and is not fixed here. No J4
baseline exists yet; J4 counters join the ratchet once three runner runs
agree.
The cause was the seed, not the query. The portal's import starts only
after its account check answers, and a freshly started mock generates the
`large` catalog on that first request (up to about 10 s on a busy machine).
`waitForXtreamImportToFinish` gave the loading overlay 5 s to appear and
then accepted "no overlay" as finished, so the seed closed the app while
the series import was still running. The template then held 4,000 live
channels and 4,000 movies but no series, and `system` matches only series.
A run stops at its first failed iteration, which is why the failure always
showed at the first launch after seeding. The wait now also requires the
catalog to be complete the way the app judges it on its next start
(`apps/electron-backend-e2e/src/xtream-import-wait.ts`): every type's
persisted status (`xtream-import-status:<id>:<type>`) reads `completed` and
the type has rows, unless the portal has no categories of that type. Rows
are required because a refresh deletes the catalog without resetting the
status. `xtream-import-wait.e2e.ts` holds the account check past the overlay
window and fails without it. No J4 baseline exists yet; J4 counters join the
ratchet once three runner runs agree.
## `renderer.initialBytes`
+53 -3
View File
@@ -73,6 +73,40 @@ The current self-hosted PWA uses these `apps/web-backend` routes:
- `GET /xtream?targetId=<id>&username=<u>&password=<p>&action=<action>`
- `GET /stalker?targetId=<id>&macAddress=<mac>&action=<action>`
Server storage adds versioned routes under `/v1`; the PWA does not call them
yet:
- `GET /v1/instance` (always public): `{ service, apiVersion, instanceId,
storage: 'server' | 'none', auth: 'none' | 'token' }`
- `GET /v1/instance/status`: playlist count, database size and path
- `GET /v1/playlists[?view=meta]`, `GET|PUT|DELETE /v1/playlists/:id`,
`DELETE /v1/playlists`: whole playlist documents; `GET` sends an `ETag`
and answers `304` to a matching `If-None-Match`, and
`PUT ...?keepPayload=1` keeps the stored `playlist`, `items` and
`header` fields (`409 payload_missing` without a stored row)
- `GET /v1/user-state`, `PUT|DELETE /v1/user-state/:namespace/:playlistId`:
Xtream favorites, recently viewed, collection snapshots and playback
positions, one JSON value per namespace and playlist
- `GET|PUT /v1/app-state/:key`
Server storage lives in `apps/web-backend/src/app/storage/`. With
`IPTVNATOR_STORAGE=server`, `main.ts` opens the shared database through
`initDatabase()` (path from `IPTVNATOR_DATA_DIR`), so the server has the same
schema and migrations as the desktop app and writes playlists with the
shared row codec. It adds two server-only tables: `server_user_state` for
Xtream user data, and `server_playlist_meta`, a copy of each document
without its M3U payload fields that metadata reads use instead of walking
the payload blobs (filled once on open for rows written without it). ETags
come from an in-memory revision counter; the server is the only writer of
its database. Storage errors are logged by error name and code only, because
Drizzle messages can carry bound values such as portal credentials. With
`IPTVNATOR_ACCESS_TOKEN`, every route except `/`, `/health`, `/config.js`
and `/v1/instance` requires `Authorization: Bearer <token>`, the proxy
routes included. The PWA cannot send that token yet, so the variable must
stay unset until the browser side lands. Browser app state written through
`/v1/app-state` is stored under a `client:` key prefix, apart from
server-owned keys such as the instance id.
`pnpm nx run web-backend:serve` starts the backend by hand on port 3333 with
`TSX_TSCONFIG_PATH=tsconfig.base.json`, which `tsx` needs for the
`@iptvnator/*` path aliases. `apps/web-e2e/playwright.config.ts` does not use
@@ -135,9 +169,10 @@ these limitations explicit in UI, troubleshooting, and release notes:
programme-guide entry points, or EPG-fetching flows in browser/PWA mode, and
do not use EPG as the readiness signal for Docker.
- The PWA does not use the Electron SQLite database or DB worker. Playlist
metadata uses `PlaylistsService` with IndexedDB; Xtream favorites, recently
viewed items, playback positions, and cached collection snapshots use
`PwaXtreamDataSource` browser storage.
metadata uses `PlaylistsService` with IndexedDB, which
`PlaylistStorageBackend` selects when no SQLite bridge exists; Xtream
favorites, recently viewed items, playback positions, and cached collection
snapshots use `PwaXtreamDataSource` browser storage.
- Browser playlist deletion must go through `PlaylistsService`, not
`DatabaseService`. `PlaylistsService.deletePlaylist()` runs registered
cleanup hooks such as the PWA Xtream cleanup so localStorage sidecar data does
@@ -234,6 +269,11 @@ and sidecar state in localStorage:
- `xtream-playlists`
- `xtream-playback-positions`
`PwaXtreamDataSource` and `PwaCollectionStorage` reach these keys through the
`USER_DATA_STORAGE` token from `@iptvnator/services` (default:
`localStorage`), never through `localStorage` directly, so a deployment can
substitute a server-synced storage.
Entries should include a content snapshot when the item is added. Global
collection routes and the dashboard can then restore titles, posters, content
type, and category IDs after navigation or a page reload without relying on the
@@ -268,6 +308,15 @@ Default runtime values:
- `PORT=3000`
- `IPTVNATOR_PROXY_ALLOW_PRIVATE_NETWORKS=0`
- `NODE_EXTRA_CA_CERTS` unset
- `IPTVNATOR_STORAGE=server`, `IPTVNATOR_DATA_DIR=/data` (a `VOLUME`)
- `IPTVNATOR_ACCESS_TOKEN` unset, `IPTVNATOR_MAX_DOCUMENT_MB=64`
- `IPTVNATOR_BACKEND_START_TIMEOUT=300`: seconds the entrypoint waits for
`/health`; the backend migrates its database before it listens
better-sqlite3 is a native addon, so the Dockerfile builds it in a separate
`native` stage that is not pinned to `$BUILDPLATFORM`; multi-arch builds
produce it for each target platform. nginx raises `client_max_body_size` to
64 MB for `/api/` and gzips JSON responses.
When hosting behind another domain, set `CLIENT_URL` to the browser origin and
keep `BACKEND_URL=/api` unless the reverse proxy exposes the backend elsewhere.
@@ -282,6 +331,7 @@ Use the narrow validation ladder for self-hosted changes:
```bash
pnpm nx test web-backend
pnpm nx test web-backend --testPathPatterns=storage
pnpm nx test web --runTestsByPath apps/web/src/app/services/runtime-config.spec.ts
pnpm nx build web --configuration=pwa --skip-nx-cache
pnpm nx build web-backend
+3 -1
View File
@@ -550,7 +550,9 @@ Xtream-only row shape:
as `Café` can still reach rows stored as either `Café` or `Cafe` before the
worker's accent-insensitive ranking step. Existing databases rebuild this
index once through the `migration:content-title-fts-trigram:v1` app-state
marker before legacy content cleanup/normalization migrations run; fresh
marker before legacy content cleanup/normalization migrations run (the
diacritics rebuild records that marker too, so it is never rebuilt twice;
see [VOD multi-source](vod-multi-source.md#short-titles-and-unicode)); fresh
inserts, deletes, and title updates stay synchronized through SQLite triggers.
2. `source_type = "m3u"` rows come from M3U playlist payloads stored in
`playlists.payload`. The worker uses SQL `payload LIKE` against channel
+4 -1
View File
@@ -833,7 +833,10 @@ titles ("Amélie" → "amelie") against an index built from the raw one — with
folding, every accented title was invisible to the FTS path, and two identical
`Amélie` entries produced no candidates at all. The tokenizer is fixed at
CREATE time, so existing databases are recreated and rebuilt once behind
`migration:content-title-fts-remove-diacritics:v1`.
`migration:content-title-fts-remove-diacritics:v1`. That rebuild also records
`migration:content-title-fts-trigram:v1`, the plain rebuild it supersedes;
before it did, a new profile rebuilt the whole index a second time on its
second launch, on the main thread, right after the first catalog import.
`remove_diacritics` needs SQLite 3.45+. The migration probes support on a temp
table first and does nothing when the runtime rejects it, leaving the working
@@ -0,0 +1,214 @@
# Server Storage for the Self-hosted PWA — Design
Date: 2026-10-08, revised 2026-10-11 against `master` at `7abf479de`.
Status: approved direction ("the server is the single source of truth in
self-hosted mode"). Implementation plans:
- `.plans/2026-10-11-server-storage.md` (overview and order)
- `.plans/2026-10-11-server-storage-phase-1-seams.md`
- `.plans/2026-10-11-server-storage-phase-2a-backend.md`
- `.plans/2026-10-11-server-storage-phase-2b-pwa.md`
Upstream demand: #305 (store playlists on the server for the Docker version,
open since 2023), #514 (closed as its duplicate), #1616 (sync watched state
and favorites between devices).
## 1. Summary
The Docker image runs the browser PWA. All user data lives in the browser:
playlists in IndexedDB, Xtream favorites, recently viewed, playback positions
and collection snapshots in localStorage. The bundled `web-backend` is a
stateless proxy, so every device that opens the NAS address starts empty.
This design gives `web-backend` an optional server storage: the same SQLite
database the Electron app uses (`libs/shared/database`, `initDatabase()`),
stored on a Docker volume, plus a small versioned HTTP API. When the instance
reports server storage, the PWA keeps playlists and playlist-scoped user data
on the server. Adding a playlist stays the ordinary "Add playlist" flow.
## 2. Goals
1. A playlist added on one device appears on every device that opens the same
self-hosted instance (M3U, Xtream, Stalker).
2. M3U and Stalker favorites and recently viewed (stored inside the playlist
document) and Xtream favorites, recently viewed, collection snapshots and
playback positions follow the playlist, not the browser.
3. Browser data moves to the server once, with consent, and is never deleted.
4. No behavior change for Electron and for PWA deployments without server
storage.
5. The HTTP contract mirrors the renderer's existing playlist document store,
so a later Electron "connect to server" phase reuses it.
## 3. Non-goals for this release
- Server-side EPG (phase 3) and Electron connecting to a server (phase 4).
- Syncing device settings (theme, language, player, EPG URLs), parental locks,
the global favorites order and UI preferences. They stay per device.
- Live push of changes (SSE). Devices read the server on every playlist read
and re-read Xtream user data when the tab becomes visible.
- User accounts. One optional shared access token.
- Credential encryption at rest. Documented limitation.
## 4. Findings that shaped the design
| Finding | Consequence |
| --- | --- |
| `PlaylistsService` persists whole playlist documents through a narrow bridge (`dbGetAppPlaylist(s)`, `dbGetAppPlaylistMetas`, `dbUpsertAppPlaylist(s)`, `dbDeletePlaylist`, `dbDeleteAllPlaylists`, `dbGet/SetAppState`). Every mutation re-reads the document, patches it and writes it back. | The server implements the same document contract over HTTP. The client change is one adapter plus a resolver; `PlaylistsService` keeps its API. |
| Electron stores the whole document as JSON in `playlists.payload`, with projected columns for fast metadata reads (`buildPlaylistRow` / `parseAppPlaylist`). | Move that codec into `libs/shared/database` and reuse it on the server. Same row format on desktop and server. |
| `initDatabase()` in `libs/shared/database` creates the full schema and runs every migration; the path comes from `path-utils`. | The server calls `initDatabase()`. A new `IPTVNATOR_DATA_DIR` variable in `path-utils` points it at the Docker volume. |
| better-sqlite3 13 is a Node-API addon. | One binary loads in Node and Electron. Only the Alpine Docker image needs its own native build stage. |
| Read-modify-write of a document carrying a large M3U payload would move megabytes per favorite click over Wi-Fi. | `GET` answers `304` on a matching ETag; an upsert whose payload object is the one the client received sends `keepPayload=1` without the payload. |
| Xtream PWA user data is four localStorage maps keyed by playlist id (`xtream-favorites`, `xtream-recent-items`, `xtream-collection-items`, `xtream-playback-positions`); `xtream-playlists` is a derived cache. | Server table `server_user_state(namespace, playlist_id, value)`. The client keeps a write-through cache under its own key prefix and sends only changed playlist entries. |
| nginx proxies `/api/` with the default 1 MB body limit. | Raise `client_max_body_size` with the server body limit. |
| The hosted PWA build points at an external parser API. | The client probes for server storage only when the deployment writes a runtime `BACKEND_URL` (the Docker entrypoint does). |
| Settings sections are grouped (App, Library, Devices, Data) and gated by capability requirements. | New "Server" section in the Data group before Backup, requirement `server-storage`. |
| New i18n keys must be translated in all 19 locales (`pnpm run i18n:check`). | Translation is a planned task. |
| Production files: target < 300 lines, hard 400; `web-backend-app.ts` (673) and `playlists.service.ts` (1580) are baselined. | New code lives in new files; baselined files only gain mounting lines. |
## 5. Product decisions
1. In server mode the server is the authority for playlists and the synced
user data. There is no per-playlist "local or server" choice.
2. Server storage is opt-in on the backend: `IPTVNATOR_STORAGE=server`. The
bare `web-backend` defaults to `none`; the Docker image sets `server`.
3. Optional `IPTVNATOR_ACCESS_TOKEN`. When set, every route except
`/`, `/health`, `/config.js` and `GET /v1/instance` requires
`Authorization: Bearer <token>`, including the provider proxy routes.
4. Migration:
- First device (server has no playlists, browser has some, no marker for
this instance): a dialog offers "Move to server". Playlists are copied
with their ids, then the four Xtream user-data maps for those ids.
- Any later device (server already has playlists): Settings → Server
offers "Import this browser's playlists", which runs the existing backup
export (browser) and import (server) with fingerprint merging, so the
same provider added on two devices is not duplicated.
- Browser data is never deleted. The server-mode cache uses its own
localStorage key prefix, so legacy keys stay intact.
5. Offline: reads and writes fail with a visible state. The rail shows an
offline server icon, a banner explains that changes cannot be saved, and
failed writes show a snackbar. No offline write queue.
6. "Delete all playlists" in server mode warns that it clears the server for
every device.
## 6. Architecture
```
Browser (PWA) Docker container
┌─────────────────────────────────────────┐ ┌───────────────────────────────────────┐
│ main.ts: probeServerInstance() │ /api │ nginx (client_max_body_size 64m) │
│ → provideServerStorage(instance) │────────►│ └─► web-backend (Express) │
│ PlaylistsService (API unchanged) │ │ ├ proxy routes (unchanged) │
│ └ PlaylistStorageBackend │ │ ├ access guard (optional) │
│ ├ Electron bridge (desktop) │ │ └ /v1 storage router │
│ ├ ServerPlaylistDocumentStore │ HTTP │ ├ instance, status │
│ └ IndexedDB (plain PWA) │◄───────►│ ├ playlists (documents) │
│ PwaXtreamDataSource │ │ ├ app-state │
│ └ USER_DATA_STORAGE │ │ └ user-state │
│ ├ localStorage │ │ ServerDatabase: initDatabase() │
│ └ ServerUserDataStorage (cache) │ │ + server_user_state │
│ ServerStorageStatusService (signals) │ │ /data/databases/iptvnator.db (WAL) │
└─────────────────────────────────────────┘ └───────────────────────────────────────┘
```
### 6.1 HTTP API (`/v1`, mounted under nginx `/api`)
All JSON. Errors: `{ message, status, code? }`.
| Route | Purpose |
| --- | --- |
| `GET /v1/instance` | Public. `{ service: 'iptvnator', apiVersion: 1, instanceId, storage: 'server' \| 'none', auth: 'none' \| 'token' }`. The frontend and backend ship in one image, so the PWA shows its own version. |
| `GET /v1/instance/status` | `{ playlists, databaseBytes, databasePath, startedAt }`. Also the token check. |
| `GET /v1/playlists?view=meta` | Metadata documents (no payload). |
| `GET /v1/playlists` | Full documents (backup export, global favorites). |
| `GET /v1/playlists/:id` | Full document, `ETag`; `If-None-Match` → `304`. |
| `PUT /v1/playlists/:id[?keepPayload=1]` | Upsert. With `keepPayload`, the stored `playlist`, `items` and `header` fields are kept; `409 payload_missing` when there is no stored row. Responds with the new `ETag`. |
| `DELETE /v1/playlists/:id` | Delete the row and its user-state entries. |
| `DELETE /v1/playlists` | Delete all playlists and all user state. |
| `GET/PUT /v1/app-state/:key` | `{ value }`. |
| `GET /v1/user-state` | `{ entries: { [namespace]: { [playlistId]: value } } }`. |
| `PUT/DELETE /v1/user-state/:namespace/:playlistId` | One entry. Namespaces are an allow-list. |
ETags are `"<bootId>-<revision>"` from an in-memory per-playlist revision
counter; the server is the only writer of its database.
### 6.2 Server
`apps/web-backend/src/app/storage/`: config, database bootstrap
(`initDatabase()` + `server_user_state` + instance id in `app_state`),
playlist repository on the shared codec, user-state repository, revision
tracker, access guard, routers. `main.ts` opens storage when enabled and
closes it on `SIGTERM`. Storage errors are logged by name and route only.
### 6.3 Client
- `PlaylistStorageBackend` (libs/services) resolves the document store:
server store in server mode, Electron bridge when `supportsSqlite`, else
IndexedDB. It can switch to browser storage temporarily for the migration
export.
- `@iptvnator/services/server-storage` (sub-entry, kept out of the eager
barrel): API client, document store, user-data storage, session, migration.
- `ServerStorageStatusService` (main barrel, tiny): connection state for the
rail, banner and Settings.
- `apps/web/src/app/server-storage/`: probe, providers (dynamically imported
from `main.ts` only in server mode), sign-in route, migration dialog,
Settings section.
## 7. UX
Mockup (2026-10-08, settings navigation has since gained groups):
https://claude.ai/artifact/3WheJAgeNL5ag6kmtPrJcD
1. Settings → Data → Server: connection pill and address,
storage figures, "Export backup", "Import this browser's playlists" (when
local playlists exist), access mode with "Sign out" when a token is used.
2. Migration dialog over the workspace on first boot (rule 5.4).
3. Rail: server icon above Settings with connected, offline and locked states.
Offline banner under the header with "Retry".
4. Sign-in page `/server-sign-in` when the instance requires a token.
## 8. Error handling
| Situation | Behavior |
| --- | --- |
| Probe fails or times out (2.5 s) | Browser mode, no server UI. |
| Request fails after boot | Status `offline`, banner, write failures show a snackbar; retry on `visibilitychange` and every 30 s. |
| `401` | Status `unauthorized`, stored token cleared, redirect to sign-in. |
| `413` | Snackbar names the limit. |
| `409 payload_missing` on `keepPayload` | Client retries once with the full document. |
| Data directory not writable | Backend exits on start with a clear log line; the container restarts. |
## 9. Testing
- Unit: codec round trip, path variable, resolver, document store (ETag,
keepPayload), user-data storage (diff, debounce, hydrate), probe, session,
migration decision, settings section, rail indicator.
- Backend: repositories on real SQLite in a temp directory; routes through a
real server and `fetch`; access guard.
- E2E: `@server-storage` spec against a storage-enabled backend on its own
port and temp directory: a playlist added in one browser context appears in
another; the migration dialog moves a seeded browser playlist. The token
page and guards are covered by unit tests (an E2E run would need a third
backend).
- Docker: `docker compose config`, image build, start with a volume.
## 10. Phases
| Phase | Content | Closes |
| --- | --- | --- |
| 1 | Seams, no behavior change: shared codec, `IPTVNATOR_DATA_DIR`, `PlaylistStorageBackend`, `USER_DATA_STORAGE`. | — |
| 2a | Server storage in `web-backend`, Docker volume, nginx limits, docs. | — |
| 2b | PWA server mode, migration, Settings → Server, rail/banner, sign-in, i18n, E2E, release note. | #305, #514 |
| 3 | Server-side EPG for the PWA. | PWA EPG gap |
| 4 | Electron connects to a server. | #1616 for NAS owners |
## 11. Risks
- **Concurrent edits on two devices**: last write wins per document or per
Xtream map entry. Every playlist mutation re-reads first, so the window is
one round trip.
- **Large payloads**: M3U global favorites and backup export read all full
documents, as IndexedDB does today.
- **Open instances**: storing credentials raises the cost of an unprotected
public instance. Docs and the Settings page state it; use the token or
reverse-proxy auth.
- **Native module in multi-arch images**: built in a target-platform stage.
@@ -6,6 +6,7 @@ import {
normalizeFavoriteStorage,
normalizeRecentStorage,
} from './pwa-collection-identity';
import type { UserDataStorage } from '@iptvnator/services';
import { XtreamContentItem } from './xtream-data-source.interface';
const STORAGE_KEYS = {
@@ -20,7 +21,8 @@ export class PwaCollectionStorage {
private readonly resolveItems: (
playlistId: string,
ids: readonly CollectionKey[]
) => Map<CollectionKey, XtreamContentItem>
) => Map<CollectionKey, XtreamContentItem>,
private readonly storage: UserDataStorage
) {}
getLocalFavorites(playlistId: string): XtreamContentItem[] {
@@ -50,7 +52,7 @@ export class PwaCollectionStorage {
getFavoritesFromStorage(): Record<string, CollectionKey[]> {
try {
const data = localStorage.getItem(STORAGE_KEYS.FAVORITES);
const data = this.storage.getItem(STORAGE_KEYS.FAVORITES);
return normalizeFavoriteStorage(data ? JSON.parse(data) : {});
} catch {
return {};
@@ -58,7 +60,7 @@ export class PwaCollectionStorage {
}
saveFavoritesToStorage(favorites: Record<string, CollectionKey[]>): void {
localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(favorites));
this.storage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(favorites));
}
clearFavoritesForPlaylist(playlistId: string): void {
@@ -124,7 +126,7 @@ export class PwaCollectionStorage {
getRecentItemsFromStorage(): Record<string, StoredRecentItem[]> {
try {
const data = localStorage.getItem(STORAGE_KEYS.RECENT_ITEMS);
const data = this.storage.getItem(STORAGE_KEYS.RECENT_ITEMS);
return normalizeRecentStorage(data ? JSON.parse(data) : {});
} catch {
return {};
@@ -134,7 +136,7 @@ export class PwaCollectionStorage {
saveRecentItemsToStorage(
recentItems: Record<string, StoredRecentItem[]>
): void {
localStorage.setItem(
this.storage.setItem(
STORAGE_KEYS.RECENT_ITEMS,
JSON.stringify(recentItems)
);
@@ -151,7 +153,7 @@ export class PwaCollectionStorage {
Record<string, XtreamContentItem>
> {
try {
const data = localStorage.getItem(STORAGE_KEYS.COLLECTION_ITEMS);
const data = this.storage.getItem(STORAGE_KEYS.COLLECTION_ITEMS);
const parsed = data ? JSON.parse(data) : {};
if (!parsed || typeof parsed !== 'object') {
return {};
@@ -165,7 +167,7 @@ export class PwaCollectionStorage {
private saveCollectionItemsToStorage(
items: Record<string, Record<string, XtreamContentItem>>
): void {
localStorage.setItem(
this.storage.setItem(
STORAGE_KEYS.COLLECTION_ITEMS,
JSON.stringify(items)
);
@@ -6,7 +6,11 @@ import {
XtreamApiService,
XtreamCredentials,
} from '../services/xtream-api.service';
import { ParentalLockService, PlaylistsService } from '@iptvnator/services';
import {
ParentalLockService,
PlaylistsService,
USER_DATA_STORAGE,
} from '@iptvnator/services';
import { of } from 'rxjs';
describe('PwaXtreamDataSource', () => {
@@ -916,4 +920,38 @@ describe('PwaXtreamDataSource', () => {
setItemSpy.mockRestore();
});
});
describe('with an injected user data storage', () => {
it('reads and writes favorites through it, not localStorage', async () => {
const values = new Map<string, string>();
const storage = {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => {
values.set(key, value);
},
removeItem: (key: string) => {
values.delete(key);
},
};
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
PwaXtreamDataSource,
{ provide: USER_DATA_STORAGE, useValue: storage },
{ provide: ParentalLockService, useValue: parentalLock },
{ provide: XtreamApiService, useValue: apiService },
{ provide: PlaylistsService, useValue: playlistsService },
],
});
const source = TestBed.inject(PwaXtreamDataSource);
values.set('xtream-favorites', JSON.stringify({ p1: ['live:1'] }));
await source.deletePlaylist('p1');
expect(JSON.parse(values.get('xtream-favorites') ?? '{}')).toEqual(
{}
);
expect(localStorage.getItem('xtream-favorites')).toBeNull();
});
});
});
@@ -18,7 +18,11 @@ import {
XtreamApiService,
XtreamCredentials,
} from '../services/xtream-api.service';
import { ParentalLockService, PlaylistsService } from '@iptvnator/services';
import {
ParentalLockService,
PlaylistsService,
USER_DATA_STORAGE,
} from '@iptvnator/services';
import { firstValueFrom } from 'rxjs';
import {
DbCategoryType,
@@ -42,7 +46,7 @@ import {
import { PwaCollectionStorage } from './pwa-collection-storage';
/**
* LocalStorage keys for PWA persistence
* Storage keys for PWA persistence
*/
const STORAGE_KEYS = {
PLAYLISTS: 'xtream-playlists',
@@ -79,7 +83,7 @@ type StoredXtreamPlaylistData = Omit<XtreamPlaylistData, 'password'> & {
/**
* PWA implementation of the Xtream data source.
* Uses API-only strategy: always fetch from API, no database caching.
* Favorites and recently viewed are stored in localStorage.
* Favorites, recently viewed and positions are stored in USER_DATA_STORAGE.
*/
@Injectable({ providedIn: 'root' })
export class PwaXtreamDataSource implements IXtreamDataSource {
@@ -88,8 +92,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
private readonly parentalLock = inject(ParentalLockService);
private readonly logger = createLogger('PwaXtreamDataSource');
private readonly contentTypes = ['live', 'movie', 'series'] as const;
private readonly userDataStorage = inject(USER_DATA_STORAGE);
private readonly collectionStorage = new PwaCollectionStorage(
(playlistId, ids) => this.getCollectionItemsById(playlistId, ids)
(playlistId, ids) => this.getCollectionItemsById(playlistId, ids),
this.userDataStorage
);
// In-memory cache for the current session
@@ -98,7 +104,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
private playlistPasswords = new Map<string, string>();
// =========================================================================
// Playlist Operations (localStorage)
// Playlist Operations (user data storage)
// =========================================================================
async getPlaylist(playlistId: string): Promise<XtreamPlaylistData | null> {
@@ -181,7 +187,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
private getPlaylistsFromStorage(): XtreamPlaylistData[] {
try {
const data = localStorage.getItem(STORAGE_KEYS.PLAYLISTS);
const data = this.userDataStorage.getItem(STORAGE_KEYS.PLAYLISTS);
const playlists = data
? (JSON.parse(data) as StoredXtreamPlaylistData[])
: [];
@@ -198,7 +204,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
const persistedPlaylists = playlists.map((playlist) =>
this.toStoredPlaylist(playlist)
);
localStorage.setItem(
this.userDataStorage.setItem(
STORAGE_KEYS.PLAYLISTS,
JSON.stringify(persistedPlaylists)
);
@@ -683,7 +689,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
}
// =========================================================================
// Favorites Operations (localStorage)
// Favorites Operations (user data storage)
// =========================================================================
async getFavorites(playlistId: string): Promise<XtreamContentItem[]> {
@@ -777,7 +783,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
}
// =========================================================================
// Playback Position Operations (localStorage)
// Playback Position Operations (user data storage)
// =========================================================================
async savePlaybackPosition(
@@ -938,7 +944,9 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
PlaybackPositionData[]
> {
try {
const data = localStorage.getItem(STORAGE_KEYS.PLAYBACK_POSITIONS);
const data = this.userDataStorage.getItem(
STORAGE_KEYS.PLAYBACK_POSITIONS
);
return data ? JSON.parse(data) : {};
} catch {
return {};
@@ -948,7 +956,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
private savePlaybackPositionsToStorage(
positions: Record<string, PlaybackPositionData[]>
): void {
localStorage.setItem(
this.userDataStorage.setItem(
STORAGE_KEYS.PLAYBACK_POSITIONS,
JSON.stringify(positions)
);
@@ -961,7 +969,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
}
// =========================================================================
// Recently Viewed Operations (localStorage)
// Recently Viewed Operations (user data storage)
// =========================================================================
async getRecentItems(playlistId: string): Promise<XtreamContentItem[]> {
+2
View File
@@ -13,6 +13,7 @@ export * from './lib/parental-lock';
export * from './lib/playlist-cross-context-lock';
export * from './lib/playlist-refresh.service';
export * from './lib/playlists.service';
export * from './lib/playlist-storage-backend';
export * from './lib/portal-status.service';
export * from './lib/runtime-capabilities.service';
export * from './lib/settings-store.service';
@@ -31,3 +32,4 @@ export * from './lib/source-health-evidence.service';
export * from './lib/source-activity.service';
export * from './lib/translation-tick';
export * from './lib/user-data-storage';
@@ -36,7 +36,10 @@ describe('Electron legacy playlist migration', () => {
) as PlaylistsService;
Object.assign(service, {
dbService,
runtime: { supportsSqlite: true },
storageBackend: {
kind: () => 'electron',
documentStore: () => window.electron,
},
electronMigrationPromise: null,
});
return { playlists, dbService, electron, service };
@@ -0,0 +1,44 @@
import { TestBed } from '@angular/core/testing';
import { PlaylistStorageBackend } from './playlist-storage-backend';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
describe('PlaylistStorageBackend', () => {
const testWindow = window as unknown as { electron?: unknown };
const originalElectron = testWindow.electron;
afterEach(() => {
testWindow.electron = originalElectron;
TestBed.resetTestingModule();
});
function create(supportsSqlite: boolean) {
TestBed.configureTestingModule({
providers: [
{
provide: RuntimeCapabilitiesService,
useValue: { supportsSqlite },
},
],
});
return TestBed.inject(PlaylistStorageBackend);
}
it('uses the Electron bridge when the SQLite bridge is complete', () => {
const bridge = { dbGetAppPlaylists: jest.fn() };
testWindow.electron = bridge;
const backend = create(true);
expect(backend.kind()).toBe('electron');
expect(backend.documentStore()).toBe(bridge);
});
it('uses IndexedDB without the SQLite bridge', () => {
testWindow.electron = { dbGetAppPlaylists: jest.fn() };
const backend = create(false);
expect(backend.kind()).toBe('indexeddb');
expect(backend.documentStore()).toBeNull();
});
});
@@ -0,0 +1,49 @@
import { inject, Injectable } from '@angular/core';
import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
/**
* Whole-playlist document persistence. The Electron preload implements it
* over the database worker; the self-hosted server storage implements the
* same contract over HTTP.
*/
export type PlaylistDocumentStore = Pick<
ElectronBridgeApi,
| 'dbDeleteAllPlaylists'
| 'dbDeletePlaylist'
| 'dbGetAppPlaylist'
| 'dbGetAppPlaylists'
| 'dbGetAppState'
| 'dbSetAppState'
| 'dbUpsertAppPlaylist'
| 'dbUpsertAppPlaylists'
> &
Partial<
Pick<
ElectronBridgeApi,
| 'dbGetAppPlaylistFavoriteChannels'
| 'dbGetAppPlaylistMetas'
| 'dbMigrateAppPlaylists'
| 'dbRecoverLegacyPlaylists'
>
>;
export type PlaylistStorageKind = 'electron' | 'indexeddb';
/** Single place that decides where `PlaylistsService` persists playlists. */
@Injectable({ providedIn: 'root' })
export class PlaylistStorageBackend {
private readonly runtime = inject(RuntimeCapabilitiesService);
kind(): PlaylistStorageKind {
return this.runtime.supportsSqlite ? 'electron' : 'indexeddb';
}
/** The document store for `kind()`, or `null` for IndexedDB. */
documentStore(): PlaylistDocumentStore | null {
if (this.kind() !== 'electron' || typeof window === 'undefined') {
return null;
}
return (window.electron as PlaylistDocumentStore | undefined) ?? null;
}
}
@@ -48,7 +48,10 @@ describe('PlaylistsService inventory reads', () => {
) as PlaylistsService;
Object.assign(service, {
dbService: { getAll: jest.fn(() => of([])) },
runtime: { supportsSqlite: true },
storageBackend: {
kind: () => 'electron',
documentStore: () => window.electron,
},
electronMigrationPromise: null,
playlistWriteQueues: new Map(),
});
+23 -16
View File
@@ -22,6 +22,23 @@ describe('PlaylistsService', () => {
jest.restoreAllMocks();
});
function hasSqliteBridge(): boolean {
const electron = testWindow.electron as
Record<string, unknown> | undefined;
return (
!!electron &&
typeof electron['dbGetAppPlaylists'] === 'function' &&
typeof electron['dbUpsertAppPlaylist'] === 'function' &&
typeof electron['dbGetAppState'] === 'function' &&
typeof electron['dbSetAppState'] === 'function'
);
}
const testStorageBackend = {
kind: () => (hasSqliteBridge() ? 'electron' : 'indexeddb'),
documentStore: () => (hasSqliteBridge() ? testWindow.electron : null),
};
function createService(overrides: Record<string, unknown> = {}) {
const service = Object.create(
PlaylistsService.prototype
@@ -45,19 +62,7 @@ describe('PlaylistsService', () => {
translateService: {
instant: jest.fn((key: string) => key),
},
runtime: {
get supportsSqlite() {
const electron = testWindow.electron as
Record<string, unknown> | undefined;
return (
!!electron &&
typeof electron['dbGetAppPlaylists'] === 'function' &&
typeof electron['dbUpsertAppPlaylist'] === 'function' &&
typeof electron['dbGetAppState'] === 'function' &&
typeof electron['dbSetAppState'] === 'function'
);
},
},
storageBackend: testStorageBackend,
electronMigrationPromise: null,
indexedDbMigrationPromise: null,
playlistWriteQueues: new Map(),
@@ -135,9 +140,11 @@ describe('PlaylistsService', () => {
finishCleanup = resolve;
})
);
Object.defineProperties(service, {
isElectronStorageAvailable: { value: true },
electronApi: { value: { dbDeletePlaylist: jest.fn() } },
Object.assign(service, {
storageBackend: {
kind: () => 'electron',
documentStore: () => ({ dbDeletePlaylist: jest.fn() }),
},
});
Object.assign(service, {
ensureElectronPlaylistMigrations: jest
+69 -94
View File
@@ -44,7 +44,10 @@ import {
runWithPlaylistAuthorityMutation,
runWithPlaylistAuthorityReset,
} from './playlist-cross-context-lock';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
import {
PlaylistDocumentStore,
PlaylistStorageBackend,
} from './playlist-storage-backend';
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
const STALKER_PLAYLIST_METADATA_MIGRATION_FLAG =
@@ -60,33 +63,6 @@ type PlaylistRawItem = {
raw?: string;
};
type PlaylistStorageElectronApi = {
dbDeleteAllPlaylists: () => Promise<unknown>;
dbDeletePlaylist: (playlistId: string) => Promise<unknown>;
dbGetAppPlaylist: (
playlistId: string,
operationId?: string
) => Promise<Playlist | null>;
dbGetAppPlaylistFavoriteChannels?: (
playlistId: string
) => Promise<M3uFavoriteChannel[]>;
dbGetAppPlaylistMetas?: () => Promise<Playlist[]>;
dbGetAppPlaylists: () => Promise<Playlist[]>;
dbGetAppState: (key: string) => Promise<string | null>;
dbSetAppState: (key: string, value: string) => Promise<unknown>;
dbUpsertAppPlaylist: (
playlist: Playlist,
operationId?: string
) => Promise<unknown>;
dbMigrateAppPlaylists: (playlists: Playlist[]) => Promise<unknown>;
dbRecoverLegacyPlaylists?: () => Promise<void>;
dbUpsertAppPlaylists: (playlists: Playlist[]) => Promise<unknown>;
};
type PlaylistStorageWindow = Window & {
electron?: PlaylistStorageElectronApi;
};
type AddManyPlaylistsResult = Playlist[] | IDBValidKey[];
type PlaylistParserModule = Partial<typeof import('iptv-playlist-parser')> & {
@@ -112,7 +88,7 @@ export class PlaylistsService {
private readonly databaseService = inject(DatabaseService);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly storageBackend = inject(PlaylistStorageBackend);
private readonly playlistDeleteCleanups =
inject(PLAYLIST_DELETE_CLEANUP, { optional: true }) ?? [];
private electronMigrationPromise: Promise<void> | null = null;
@@ -125,16 +101,18 @@ export class PlaylistsService {
private indexedDbMigrationPromise: Promise<void> | null = null;
private readonly playlistWriteQueues = new Map<string, Promise<unknown>>();
private get electronApi(): PlaylistStorageElectronApi | null {
if (typeof window === 'undefined') {
return null;
}
return (window as PlaylistStorageWindow).electron ?? null;
/** Electron bridge or server store; `null` means IndexedDB. */
private get documentStore(): PlaylistDocumentStore | null {
return this.storageBackend.documentStore();
}
private get isElectronStorageAvailable(): boolean {
return this.runtime.supportsSqlite;
private get usesDocumentStore(): boolean {
return this.storageBackend.kind() !== 'indexeddb';
}
/** Desktop-only paths: legacy IndexedDB import, worker deletion. */
private get usesElectronStorage(): boolean {
return this.storageBackend.kind() === 'electron';
}
private runOnSqlite<T>(operation: () => Promise<T>) {
@@ -150,14 +128,14 @@ export class PlaylistsService {
}
private async ensureElectronPlaylistMigrations(): Promise<void> {
if (!this.isElectronStorageAvailable) {
if (!this.usesElectronStorage) {
return;
}
if (!this.electronMigrationPromise) {
this.electronMigrationPromise = (async () => {
await this.migrateIndexedDbPlaylistsToSqlite();
await this.electronApi?.dbRecoverLegacyPlaylists?.();
await this.documentStore?.dbRecoverLegacyPlaylists?.();
await this.migrateStalkerPlaylistMetadataInSqlite();
})().catch((error) => {
this.electronMigrationPromise = null;
@@ -169,7 +147,7 @@ export class PlaylistsService {
}
private async ensureIndexedDbPlaylistMigrations(): Promise<void> {
if (this.isElectronStorageAvailable) {
if (this.usesDocumentStore) {
return;
}
@@ -196,12 +174,9 @@ export class PlaylistsService {
}
private async migrateIndexedDbPlaylistsToSqlite(): Promise<void> {
const electron = this.electronApi;
if (!electron) return;
if (
(await electron.dbGetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG)) ===
'1'
)
const store = this.documentStore;
if (!store?.dbMigrateAppPlaylists) return;
if ((await store.dbGetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG)) === '1')
return;
const playlists = await firstValueFrom(
this.dbService.getAll<Playlist>(DbStores.Playlists)
@@ -209,9 +184,9 @@ export class PlaylistsService {
if (playlists.length) {
// The worker commits rows and the receipt atomically. Keep the
// original IndexedDB as a recovery source, even after success.
await electron.dbMigrateAppPlaylists(playlists);
await store.dbMigrateAppPlaylists(playlists);
} else {
await electron.dbSetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG, '1');
await store.dbSetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG, '1');
}
}
@@ -315,12 +290,12 @@ export class PlaylistsService {
private async migrateStalkerPlaylistMetadataInSqlite(): Promise<void> {
try {
const electron = this.electronApi;
if (!electron) {
const store = this.documentStore;
if (!store) {
return;
}
const alreadyMigrated = await electron.dbGetAppState(
const alreadyMigrated = await store.dbGetAppState(
STALKER_PLAYLIST_METADATA_MIGRATION_FLAG
);
if (alreadyMigrated === '1') {
@@ -328,15 +303,15 @@ export class PlaylistsService {
}
const storedPlaylists =
(await electron.dbGetAppPlaylists()) as Playlist[];
(await store.dbGetAppPlaylists()) as Playlist[];
const updates =
this.collectStalkerMetadataMigrationUpdates(storedPlaylists);
if (updates.length > 0) {
await electron.dbUpsertAppPlaylists(updates);
await store.dbUpsertAppPlaylists(updates);
}
await electron.dbSetAppState(
await store.dbSetAppState(
STALKER_PLAYLIST_METADATA_MIGRATION_FLAG,
'1'
);
@@ -398,16 +373,16 @@ export class PlaylistsService {
private upsertSqlitePlaylist(playlist: Playlist, operationId?: string) {
return this.runOnSqlite(async () => {
const electron = this.electronApi;
if (!electron) {
const store = this.documentStore;
if (!store) {
return playlist;
}
this.startupMetas = null;
if (operationId === undefined) {
await electron.dbUpsertAppPlaylist(playlist);
await store.dbUpsertAppPlaylist(playlist);
} else {
await electron.dbUpsertAppPlaylist(playlist, operationId);
await store.dbUpsertAppPlaylist(playlist, operationId);
}
this.healthEvidence?.connections.next({ id: playlist._id, playlist });
return playlist;
@@ -416,13 +391,13 @@ export class PlaylistsService {
private upsertManySqlitePlaylists(playlists: Playlist[]) {
return this.runOnSqlite(async () => {
const electron = this.electronApi;
if (!electron) {
const store = this.documentStore;
if (!store) {
return playlists;
}
this.startupMetas = null;
await electron.dbUpsertAppPlaylists(playlists);
await store.dbUpsertAppPlaylists(playlists);
playlists.forEach((playlist) => this.healthEvidence?.connections.next({ id: playlist._id, playlist }));
return playlists;
});
@@ -457,7 +432,7 @@ export class PlaylistsService {
nextPlaylist: Playlist,
operationId?: string
) {
if (this.isElectronStorageAvailable) {
if (this.usesDocumentStore) {
return firstValueFrom(
this.upsertSqlitePlaylist(nextPlaylist, operationId)
);
@@ -499,16 +474,16 @@ export class PlaylistsService {
}
getAllPlaylists() {
if (this.isElectronStorageAvailable) {
if (this.usesDocumentStore) {
const shared = this.startupMetas;
// A joining caller gets its own copy of the shared result.
if (shared) return from(shared.then((p) => structuredClone(p)));
const read = firstValueFrom(
this.runOnSqlite(async () => {
const electron = this.electronApi;
const playlists = electron
? await (electron.dbGetAppPlaylistMetas?.() ??
electron.dbGetAppPlaylists())
const store = this.documentStore;
const playlists = store
? await (store.dbGetAppPlaylistMetas?.() ??
store.dbGetAppPlaylists())
: [];
return (playlists as Playlist[]).map((playlist) =>
this.toPlaylistMeta(playlist)
@@ -533,7 +508,7 @@ export class PlaylistsService {
addPlaylist(playlist: Playlist) {
return this.serializePlaylistWrite(playlist._id, () =>
runWithPlaylistAuthorityMutation([playlist._id], async () => {
if (this.isElectronStorageAvailable) {
if (this.usesDocumentStore) {
return firstValueFrom(this.upsertSqlitePlaylist(playlist));
}
@@ -570,12 +545,12 @@ export class PlaylistsService {
playlistId,
() =>
runWithPlaylistAuthorityMutation([playlistId], async () => {
if (this.isElectronStorageAvailable) {
if (this.usesDocumentStore) {
await this.ensureElectronPlaylistMigrations();
const electron = this.electronApi;
if (electron) {
const store = this.documentStore;
if (store) {
this.startupMetas = null;
if (options) {
if (options && this.usesElectronStorage) {
const deleted =
await this.databaseService.deletePlaylist(
playlistId,
@@ -585,7 +560,7 @@ export class PlaylistsService {
throw new Error(
'Playlist deletion did not complete'
);
} else await electron.dbDeletePlaylist(playlistId);
} else await store.dbDeletePlaylist(playlistId);
}
return undefined;
}
@@ -707,15 +682,15 @@ export class PlaylistsService {
}
getPlaylistById(id: string, operationId?: string) {
if (this.isElectronStorageAvailable) {
if (this.usesDocumentStore) {
return this.runOnSqlite(async () => {
const electron = this.electronApi;
const store = this.documentStore;
let playlist: Playlist | null = null;
if (electron) {
if (store) {
playlist =
operationId === undefined
? await electron.dbGetAppPlaylist(id)
: await electron.dbGetAppPlaylist(id, operationId);
? await store.dbGetAppPlaylist(id)
: await store.dbGetAppPlaylist(id, operationId);
}
return playlist
? this.createSqliteFallbackPlaylist(playlist as Playlist)
@@ -754,7 +729,7 @@ export class PlaylistsService {
isCurrent?: (playlist: Playlist) => boolean
): Observable<Playlist | null> {
return this.serializePlaylistWrite(updatedPlaylist._id, async () => {
if (isCurrent && !this.isElectronStorageAvailable) {
if (isCurrent && !this.usesDocumentStore) {
await this.ensureIndexedDbPlaylistMigrations();
return this.transformIndexedDbPlaylistMeta(
updatedPlaylist._id,
@@ -1010,7 +985,7 @@ export class PlaylistsService {
}
return this.serializePlaylistWrite(playlistId, async () => {
if (!this.isElectronStorageAvailable) {
if (!this.usesDocumentStore) {
await this.ensureIndexedDbPlaylistMigrations();
return this.transformIndexedDbPlaylistMeta(
playlistId,
@@ -1080,11 +1055,11 @@ export class PlaylistsService {
getM3uFavoriteChannels(
playlistId: string
): Observable<M3uFavoriteChannel[] | null> {
const electron = this.electronApi;
const getFavoriteChannels = electron?.dbGetAppPlaylistFavoriteChannels;
const store = this.documentStore;
const getFavoriteChannels = store?.dbGetAppPlaylistFavoriteChannels;
if (
!electron ||
!this.isElectronStorageAvailable ||
!store ||
!this.usesElectronStorage ||
typeof getFavoriteChannels !== 'function'
) {
return of(null);
@@ -1095,7 +1070,7 @@ export class PlaylistsService {
// A favorite toggled just before this read may still be queued;
// the queue tail never rejects.
await this.playlistWriteQueues.get(playlistId);
const alreadyMigrated = await electron.dbGetAppState(
const alreadyMigrated = await store.dbGetAppState(
SQLITE_PLAYLIST_MIGRATION_FLAG
);
if (alreadyMigrated !== '1') {
@@ -1273,7 +1248,7 @@ export class PlaylistsService {
runWithPlaylistAuthorityMutation(
playlists.map((playlist) => playlist._id),
async () => {
if (this.isElectronStorageAvailable) {
if (this.usesDocumentStore) {
return firstValueFrom(
this.upsertManySqlitePlaylists(playlists)
);
@@ -1328,11 +1303,11 @@ export class PlaylistsService {
}
getAllData() {
if (this.isElectronStorageAvailable) {
if (this.usesDocumentStore) {
return this.runOnSqlite(async () => {
const electron = this.electronApi;
return electron
? ((await electron.dbGetAppPlaylists()) as Playlist[])
const store = this.documentStore;
return store
? ((await store.dbGetAppPlaylists()) as Playlist[])
: [];
});
}
@@ -1345,12 +1320,12 @@ export class PlaylistsService {
removeAll(): Observable<void> {
return defer(() =>
runWithPlaylistAuthorityReset(async () => {
if (this.isElectronStorageAvailable) {
if (this.usesDocumentStore) {
await this.ensureElectronPlaylistMigrations();
const electron = this.electronApi;
if (electron) {
const store = this.documentStore;
if (store) {
this.startupMetas = null;
await electron.dbDeleteAllPlaylists();
await store.dbDeleteAllPlaylists();
this.healthEvidence?.connections.next({});
}
return undefined;
@@ -0,0 +1,34 @@
import { TestBed } from '@angular/core/testing';
import { USER_DATA_STORAGE } from './user-data-storage';
describe('USER_DATA_STORAGE', () => {
afterEach(() => {
localStorage.clear();
jest.restoreAllMocks();
TestBed.resetTestingModule();
});
it('delegates to localStorage by default', () => {
const storage = TestBed.inject(USER_DATA_STORAGE);
storage.setItem('xtream-favorites', '{}');
expect(localStorage.getItem('xtream-favorites')).toBe('{}');
expect(storage.getItem('xtream-favorites')).toBe('{}');
storage.removeItem('xtream-favorites');
expect(localStorage.getItem('xtream-favorites')).toBeNull();
});
it('does not touch localStorage until a method is called', () => {
// A browser that denies storage throws on the localStorage getter
// itself; reading it only inside calls keeps that failure in the
// callers' guarded reads instead of in service construction.
const getter = jest.spyOn(globalThis, 'localStorage', 'get');
const storage = TestBed.inject(USER_DATA_STORAGE);
expect(getter).not.toHaveBeenCalled();
storage.getItem('xtream-favorites');
expect(getter).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,31 @@
import { InjectionToken } from '@angular/core';
/** The slice of the Web Storage API that PWA user-data stores rely on. */
export type UserDataStorage = Pick<
Storage,
'getItem' | 'setItem' | 'removeItem'
>;
/**
* `localStorage` behind an adapter that reads the global only inside each
* call: when the browser denies storage access, reading `localStorage`
* itself throws, and that must happen in the callers' guarded reads and
* writes, not while Angular constructs the service that injects this.
*/
export function createBrowserUserDataStorage(): UserDataStorage {
return {
getItem: (key) => globalThis.localStorage.getItem(key),
setItem: (key, value) => globalThis.localStorage.setItem(key, value),
removeItem: (key) => globalThis.localStorage.removeItem(key),
};
}
/**
* Browser persistence for PWA user data such as Xtream favorites, recently
* viewed items and playback positions. Defaults to `localStorage`; the
* self-hosted server mode provides a server-synced implementation.
*/
export const USER_DATA_STORAGE = new InjectionToken<UserDataStorage>(
'USER_DATA_STORAGE',
{ providedIn: 'root', factory: createBrowserUserDataStorage }
);
+7 -1
View File
@@ -33,7 +33,13 @@ import { content, categories, playlists, type Content } from '@iptvnator/shared/
## Database Location
The SQLite database is stored at: `~/.iptvnator/databases/iptvnator.db`
The SQLite database is stored at `<data root>/databases/iptvnator.db`. The data
root is resolved in this order:
1. `IPTVNATOR_E2E_DATA_DIR` (E2E runs only)
2. `IPTVNATOR_DATA_DIR` (an operator-selected data root for deployments
with a mounted volume, for example `/data`)
3. `~/.iptvnator`
## Upgrade Compatibility And Migrations
+1
View File
@@ -8,3 +8,4 @@ export * from './lib/schema';
export * from './lib/connection';
export * from './lib/path-utils';
export * from './lib/connection-observer';
export * from './lib/app-playlist';
@@ -0,0 +1,136 @@
import {
buildPlaylistRow,
parseAppPlaylist,
PLAYLIST_TYPES,
} from './app-playlist-codec';
import type { NewPlaylist, Playlist as PlaylistRow } from './schema';
function rowOf(document: Record<string, unknown>): NewPlaylist {
const row = buildPlaylistRow(document);
if (!row) {
throw new Error('Expected the document to produce a row');
}
return row;
}
/** What SQLite hands back for a row written from `row`. */
function storedRow(row: NewPlaylist, payload?: string | null): PlaylistRow {
const nullable = Object.fromEntries(
Object.entries(row).map(([key, value]) => [key, value ?? null])
);
return {
dateCreated: '2026-10-01 00:00:00',
...nullable,
payload: payload === undefined ? (row.payload ?? null) : payload,
} as PlaylistRow;
}
describe('app playlist codec', () => {
const m3uDocument = {
_id: 'm3u-1',
title: 'Family',
url: 'https://example.test/list.m3u',
count: 1,
importDate: '2026-10-01T00:00:00.000Z',
lastUsage: '2026-10-02T00:00:00.000Z',
favorites: ['https://example.test/a'],
recentlyViewed: [],
autoRefresh: true,
hiddenGroupTitles: ['Adult'],
playlist: {
header: { raw: '#EXTM3U' },
items: [
{
id: 'a',
url: 'https://example.test/a',
raw: '#EXTINF:-1,A',
},
],
},
};
it('round-trips an M3U document through a stored row', () => {
const row = rowOf(m3uDocument);
expect(row).toMatchObject({
id: 'm3u-1',
name: 'Family',
type: PLAYLIST_TYPES.M3U_URL,
url: 'https://example.test/list.m3u',
autoRefresh: true,
count: 1,
});
expect(parseAppPlaylist(storedRow(row))).toMatchObject({
_id: 'm3u-1',
title: 'Family',
favorites: ['https://example.test/a'],
hiddenGroupTitles: ['Adult'],
playlist: m3uDocument.playlist,
});
});
it('reads metadata from columns when the payload is not selected', () => {
const row = rowOf(m3uDocument);
const meta = parseAppPlaylist(storedRow(row, null));
expect(meta).toMatchObject({
_id: 'm3u-1',
title: 'Family',
count: 1,
favorites: ['https://example.test/a'],
url: 'https://example.test/list.m3u',
});
expect(meta['playlist']).toBeUndefined();
});
it('infers the provider type from the document', () => {
expect(
buildPlaylistRow({ _id: 'x', serverUrl: 'http://panel.test' })?.type
).toBe(PLAYLIST_TYPES.XTREAM);
expect(
buildPlaylistRow({
_id: 's',
macAddress: '00:1A:79:00:00:01',
portalUrl: 'http://portal.test/c',
})
).toMatchObject({
type: PLAYLIST_TYPES.STALKER,
url: 'http://portal.test/c',
});
expect(
buildPlaylistRow({ _id: 'f', filePath: '/tmp/list.m3u' })?.type
).toBe(PLAYLIST_TYPES.M3U_FILE);
expect(buildPlaylistRow({ _id: 't' })?.type).toBe(
PLAYLIST_TYPES.M3U_TEXT
);
});
it('refuses a document without an id', () => {
expect(buildPlaylistRow({ title: 'No id' })).toBeNull();
});
it('never logs the content of a malformed stored value', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {
/* silenced */
});
try {
const row = rowOf(m3uDocument);
parseAppPlaylist(
storedRow(row, '{"title":"x","password":hunter2-secret}')
);
const logged = warn.mock.calls
.flat()
.map((arg) =>
arg instanceof Error
? `${arg.name}: ${arg.message} ${arg.stack}`
: String(arg)
)
.join(' ');
expect(warn).toHaveBeenCalled();
expect(logged).not.toContain('hunter2');
} finally {
warn.mockRestore();
}
});
});
@@ -0,0 +1,268 @@
/**
* Row codec for the `playlists` table. The whole renderer `Playlist`
* document is stored as JSON in `payload`; the fields that metadata reads
* need are projected into columns. The Electron database worker and the
* self-hosted web backend both use this module, so both write the same rows.
*/
import * as schema from './schema';
export const PLAYLIST_TYPES = {
XTREAM: 'xtream',
STALKER: 'stalker',
M3U_FILE: 'm3u-file',
M3U_TEXT: 'm3u-text',
M3U_URL: 'm3u-url',
} as const;
export type AppPlaylistType =
(typeof PLAYLIST_TYPES)[keyof typeof PLAYLIST_TYPES];
const PLAYLIST_TYPE_VALUES = new Set<AppPlaylistType>(
Object.values(PLAYLIST_TYPES)
);
function getStringValue(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
}
function getNumericValue(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value.trim() !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
export function parseJsonValue<T>(
value: string | null | undefined,
fallback: T
): T {
if (!value) {
return fallback;
}
try {
return JSON.parse(value) as T;
} catch (error) {
// The parser's message quotes the stored text, which can hold
// portal credentials; the error name is enough to diagnose.
console.warn(
'Failed to parse JSON value from DB:',
error instanceof Error ? error.name : typeof error
);
return fallback;
}
}
function getStringArrayValue(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item) => item.length > 0);
}
function inferPlaylistType(playlist: Record<string, unknown>): AppPlaylistType {
const explicitType = getStringValue(playlist['type']);
if (
explicitType &&
PLAYLIST_TYPE_VALUES.has(explicitType as AppPlaylistType)
) {
return explicitType as AppPlaylistType;
}
if (getStringValue(playlist['serverUrl'])) {
return PLAYLIST_TYPES.XTREAM;
}
if (getStringValue(playlist['macAddress'])) {
return PLAYLIST_TYPES.STALKER;
}
if (getStringValue(playlist['filePath'])) {
return PLAYLIST_TYPES.M3U_FILE;
}
if (getStringValue(playlist['url'])) {
return PLAYLIST_TYPES.M3U_URL;
}
return PLAYLIST_TYPES.M3U_TEXT;
}
export function buildPlaylistRow(
playlist: Record<string, unknown>
): schema.NewPlaylist | null {
const id =
getStringValue(playlist['_id']) ?? getStringValue(playlist['id']);
if (!id) {
return null;
}
const type = inferPlaylistType(playlist);
const portalUrl = getStringValue(playlist['portalUrl']);
const url = getStringValue(playlist['url']);
const nowIso = new Date().toISOString();
const updateDate = getNumericValue(playlist['updateDate']);
return {
id,
name:
getStringValue(playlist['title']) ??
getStringValue(playlist['name']) ??
id,
serverUrl: getStringValue(playlist['serverUrl']),
username: getStringValue(playlist['username']),
password: getStringValue(playlist['password']),
lastUpdated:
typeof updateDate === 'number'
? new Date(updateDate).toISOString()
: getStringValue(playlist['lastUpdated']),
type,
userAgent: getStringValue(playlist['userAgent']),
origin: getStringValue(playlist['origin']),
referrer: getStringValue(playlist['referrer']),
filePath: getStringValue(playlist['filePath']),
epgUrls:
playlist['epgUrls'] !== undefined
? JSON.stringify(getStringArrayValue(playlist['epgUrls']))
: undefined,
detectedEpgUrls:
playlist['detectedEpgUrls'] !== undefined
? JSON.stringify(
getStringArrayValue(playlist['detectedEpgUrls'])
)
: undefined,
manualEpgUrls:
playlist['manualEpgUrls'] !== undefined
? JSON.stringify(getStringArrayValue(playlist['manualEpgUrls']))
: undefined,
disabledEpgUrls:
playlist['disabledEpgUrls'] !== undefined
? JSON.stringify(
getStringArrayValue(playlist['disabledEpgUrls'])
)
: undefined,
autoRefresh: Boolean(playlist['autoRefresh']),
macAddress: getStringValue(playlist['macAddress']),
url: type === PLAYLIST_TYPES.STALKER ? (portalUrl ?? url) : url,
portalUrl,
count: getNumericValue(playlist['count']),
importDate: getStringValue(playlist['importDate']),
updateDate,
position: getNumericValue(playlist['position']),
favorites:
playlist['favorites'] !== undefined
? JSON.stringify(playlist['favorites'])
: undefined,
recentlyViewed:
playlist['recentlyViewed'] !== undefined
? JSON.stringify(playlist['recentlyViewed'])
: undefined,
payload: JSON.stringify(playlist),
lastUsage: getStringValue(playlist['lastUsage']) ?? nowIso,
};
}
export function getPlaylistItemCount(
playlist: Record<string, unknown>
): number | undefined {
try {
const value = playlist['playlist'];
if (typeof value !== 'object' || value === null) {
return undefined;
}
const items = Reflect.get(value, 'items');
return Array.isArray(items) ? items.length : undefined;
} catch {
return undefined;
}
}
export function parseAppPlaylist(
row: schema.Playlist
): Record<string, unknown> {
const payload = parseJsonValue<Record<string, unknown> | null>(
row.payload,
null
);
const base = payload && typeof payload === 'object' ? payload : {};
const favorites = parseJsonValue<unknown[]>(row.favorites, []);
const recentlyViewed = parseJsonValue<unknown[]>(row.recentlyViewed, []);
const epgUrls = getStringArrayValue(
row.epgUrls
? parseJsonValue<unknown[]>(row.epgUrls, [])
: base['epgUrls']
);
const detectedEpgUrls = getStringArrayValue(
row.detectedEpgUrls
? parseJsonValue<unknown[]>(row.detectedEpgUrls, [])
: (base['detectedEpgUrls'] ?? epgUrls)
);
const manualEpgUrls = getStringArrayValue(
row.manualEpgUrls
? parseJsonValue<unknown[]>(row.manualEpgUrls, [])
: base['manualEpgUrls']
);
const disabledEpgUrls = getStringArrayValue(
row.disabledEpgUrls
? parseJsonValue<unknown[]>(row.disabledEpgUrls, [])
: base['disabledEpgUrls']
);
const importDate =
row.importDate ?? row.dateCreated ?? new Date().toISOString();
const portalUrl =
row.portalUrl ?? (row.type === PLAYLIST_TYPES.STALKER ? row.url : null);
const updateDate =
row.updateDate ??
(row.lastUpdated ? new Date(row.lastUpdated).getTime() : undefined);
return {
...base,
_id: row.id,
title:
getStringValue(base['title']) ??
getStringValue(base['name']) ??
row.name,
count: row.count ?? getNumericValue(base['count']) ?? 0,
importDate: getStringValue(base['importDate']) ?? importDate,
lastUsage:
row.lastUsage ??
getStringValue(base['lastUsage']) ??
getStringValue(base['importDate']) ??
importDate,
favorites,
recentlyViewed,
autoRefresh: row.autoRefresh ?? Boolean(base['autoRefresh']),
url:
row.type === PLAYLIST_TYPES.M3U_URL
? (row.url ?? getStringValue(base['url']))
: getStringValue(base['url']),
filePath: row.filePath ?? getStringValue(base['filePath']),
epgUrls,
detectedEpgUrls,
manualEpgUrls,
disabledEpgUrls,
userAgent: row.userAgent ?? getStringValue(base['userAgent']),
referrer: row.referrer ?? getStringValue(base['referrer']),
origin: row.origin ?? getStringValue(base['origin']),
updateDate,
position: row.position ?? getNumericValue(base['position']),
serverUrl: row.serverUrl ?? getStringValue(base['serverUrl']),
username: row.username ?? getStringValue(base['username']),
password: row.password ?? getStringValue(base['password']),
macAddress: row.macAddress ?? getStringValue(base['macAddress']),
portalUrl: portalUrl ?? getStringValue(base['portalUrl']),
};
}
@@ -0,0 +1,81 @@
/**
* The `DO UPDATE` half of an app-playlist upsert and the payload clock
* reader, shared with the self-hosted web backend. The conditional writers
* that complete this contract stay in the Electron backend's
* `playlist-server-timezone.operations.ts` (issue #1562).
*/
import { sql, type SQL } from 'drizzle-orm';
import * as schema from './schema';
export type PlaylistConflictUpdate = {
[K in keyof schema.NewPlaylist]?: schema.NewPlaylist[K] | SQL;
};
const stored = schema.playlists;
/**
* The `DO UPDATE` half of an app-playlist upsert. A full upsert is built
* from a snapshot the caller read earlier; when that snapshot carries no
* panel clock while the stored row does — `setPlaylistServerTimezone`
* landed in between — the stored clock is carried over as long as the row
* still points at the same panel, so a favorites, recent-items or metadata
* write cannot hand a clockless payload back to the past. A snapshot that
* carries its own clock, or moves the source, wins as is. Nested CASE, not
* AND: SQLite may reorder AND terms, and the json_* readers raise on a
* malformed payload unless json_valid ran first.
*/
export function playlistConflictUpdate(
row: schema.NewPlaylist,
playlist: Record<string, unknown>
): PlaylistConflictUpdate {
if (readString(playlist['serverTimezone'])) {
return row;
}
const excludedPayload = sql.raw(`excluded.${stored.payload.name}`);
const payload = sql`CASE
WHEN json_valid(${stored.payload})
THEN CASE
WHEN json_type(${stored.payload}, '$.serverTimezone') = 'text'
AND ${stored.serverUrl} IS excluded.${sql.raw(stored.serverUrl.name)}
AND ${stored.username} IS excluded.${sql.raw(stored.username.name)}
AND ${stored.password} IS excluded.${sql.raw(stored.password.name)}
THEN json_set(
${excludedPayload},
'$.serverTimezone',
json_extract(${stored.payload}, '$.serverTimezone')
)
ELSE ${excludedPayload}
END
ELSE ${excludedPayload}
END`;
return { ...row, payload };
}
/**
* The clock stored in a raw row's payload, for `DB_GET_PLAYLIST`: the
* Xtream store seeds `currentPlaylist` from that read before (or without)
* the account-info check that learns the value.
*/
export function readPayloadServerTimezone(
payload: string | null | undefined
): string | undefined {
if (!payload) {
return undefined;
}
try {
const parsed: unknown = JSON.parse(payload);
return parsed && typeof parsed === 'object'
? readString((parsed as Record<string, unknown>)['serverTimezone'])
: undefined;
} catch {
return undefined;
}
}
function readString(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
}
@@ -0,0 +1,2 @@
export * from './app-playlist-codec';
export * from './app-playlist-conflict';
@@ -358,7 +358,12 @@ describe('content title FTS tokenizer upgrade', () => {
expect.stringContaining("tokenize='trigram remove_diacritics 1'")
);
expect(rebuild.run).toHaveBeenCalled();
expect(marker.run).toHaveBeenCalled();
// The rebuild also covers the plain index migration; without its
// record the next launch rebuilds the whole index again.
expect(marker.run.mock.calls).toEqual([
['migration:content-title-fts-remove-diacritics:v1'],
['migration:content-title-fts-trigram:v1'],
]);
// One transaction, so a rejected CREATE rolls the drop back and the
// working index survives.
expect(transaction).toHaveBeenCalled();
+13 -9
View File
@@ -755,15 +755,19 @@ function upgradeContentTitleFtsTokenizer(sqliteDb: Database.Database): boolean {
)
.run();
sqliteDb
.prepare(
`INSERT INTO app_state (key, value, updated_at)
VALUES (?, 'done', datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at`
)
.run(CONTENT_TITLE_FTS_DIACRITICS_MIGRATION_KEY);
// The rebuild is also all that `ensureContentTitleFts` does, so
// its record is written too. Without it the next launch rebuilds
// the whole index again, on the main thread, on the first launch
// after a fresh install has imported its catalog.
const markDone = sqliteDb.prepare(
`INSERT INTO app_state (key, value, updated_at)
VALUES (?, 'done', datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at`
);
markDone.run(CONTENT_TITLE_FTS_DIACRITICS_MIGRATION_KEY);
markDone.run(CONTENT_TITLE_FTS_MIGRATION_KEY);
});
executeMigration();
@@ -9,6 +9,7 @@ import { existsSync, mkdtempSync, rmSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
IPTVNATOR_DATA_DIR_ENV,
IPTVNATOR_E2E_DATA_DIR_ENV,
getElectronConfigDirectory,
getElectronUserDataPath,
@@ -20,12 +21,15 @@ import {
describe('path-utils', () => {
let tempRoot: string;
let originalEnvValue: string | undefined;
let originalDataDirValue: string | undefined;
beforeEach(() => {
tempRoot = mkdtempSync(join(tmpdir(), 'iptvnator-path-utils-'));
homedirMock.mockReturnValue(join(tempRoot, 'home'));
originalEnvValue = process.env[IPTVNATOR_E2E_DATA_DIR_ENV];
delete process.env[IPTVNATOR_E2E_DATA_DIR_ENV];
originalDataDirValue = process.env[IPTVNATOR_DATA_DIR_ENV];
delete process.env[IPTVNATOR_DATA_DIR_ENV];
});
afterEach(() => {
@@ -34,9 +38,41 @@ describe('path-utils', () => {
} else {
process.env[IPTVNATOR_E2E_DATA_DIR_ENV] = originalEnvValue;
}
if (originalDataDirValue === undefined) {
delete process.env[IPTVNATOR_DATA_DIR_ENV];
} else {
process.env[IPTVNATOR_DATA_DIR_ENV] = originalDataDirValue;
}
rmSync(tempRoot, { force: true, recursive: true });
});
it('uses and creates IPTVNATOR_DATA_DIR as the data root', () => {
const dataDir = join(tempRoot, 'volume');
process.env[IPTVNATOR_DATA_DIR_ENV] = ` ${dataDir} `;
expect(getIptvnatorDataRoot()).toBe(dataDir);
expect(existsSync(dataDir)).toBe(true);
expect(getIptvnatorDatabasePath()).toBe(
join(dataDir, 'databases', 'iptvnator.db')
);
});
it('lets the E2E override win over IPTVNATOR_DATA_DIR', () => {
const e2eDir = join(tempRoot, 'e2e');
process.env[IPTVNATOR_E2E_DATA_DIR_ENV] = e2eDir;
process.env[IPTVNATOR_DATA_DIR_ENV] = join(tempRoot, 'volume');
expect(getIptvnatorDataRoot()).toBe(e2eDir);
});
it('ignores a blank IPTVNATOR_DATA_DIR', () => {
process.env[IPTVNATOR_DATA_DIR_ENV] = ' ';
expect(getIptvnatorDataRoot()).toBe(
join(tempRoot, 'home', '.iptvnator')
);
});
it('uses and creates the E2E data dir override when the env variable is set', () => {
const e2eDataDir = join(tempRoot, 'e2e-data');
process.env[IPTVNATOR_E2E_DATA_DIR_ENV] = e2eDataDir;
@@ -4,6 +4,12 @@ import { join } from 'path';
export const IPTVNATOR_E2E_DATA_DIR_ENV = 'IPTVNATOR_E2E_DATA_DIR';
/**
* Operator-selected data root for deployments that keep the database on a
* mounted volume, for example `/data`. The E2E override wins.
*/
export const IPTVNATOR_DATA_DIR_ENV = 'IPTVNATOR_DATA_DIR';
function ensureDirectory(dirPath: string): string {
if (!existsSync(dirPath)) {
mkdirSync(dirPath, { recursive: true });
@@ -19,6 +25,12 @@ export function getIptvnatorDataRoot(): string {
return ensureDirectory(e2eDataDir);
}
const dataDir = process.env[IPTVNATOR_DATA_DIR_ENV]?.trim();
if (dataDir) {
return ensureDirectory(dataDir);
}
return ensureDirectory(join(homedir(), '.iptvnator'));
}
@@ -71,6 +71,30 @@ function seed(sqlite: Database.Database) {
}
}
const titleIndexRebuilds: string[] = [];
/**
* Every full rebuild of the title FTS index goes through `prepare`. A profile
* needs one; another on a later launch re-indexes the whole catalog on the
* main thread before the app can read anything.
*/
function countTitleIndexRebuilds(connection: Database.Database): void {
const prepare = connection.prepare;
connection.prepare = function countingPrepare(
this: Database.Database,
sql: string
) {
if (
/content_title_fts\(content_title_fts\)\s+VALUES\s+\('rebuild'\)/.test(
sql
)
) {
titleIndexRebuilds.push(sql);
}
return prepare.call(this, sql);
} as typeof connection.prepare;
}
/**
* The performance capture counts one `exec` call as one SQL statement
* (apps/electron-backend/src/app/workers/database-worker-sql-statement-count.ts),
@@ -81,6 +105,7 @@ function seed(sqlite: Database.Database) {
function requireSingleStatementExec(): string[] {
const batches: string[] = [];
setDatabaseConnectionObserver((connection) => {
countTitleIndexRebuilds(connection);
const exec = connection.exec;
connection.exec = function singleStatementExec(sql: string) {
try {
@@ -230,6 +255,11 @@ async function main() {
[],
'Initialization must pass one SQL statement per exec call'
);
assert.equal(
titleIndexRebuilds.length,
1,
'The title index must be rebuilt once per profile, not on every launch'
);
process.stdout.write('upgrade verified');
}
+1
View File
@@ -112,3 +112,4 @@ export * from './lib/xtream-connection-test';
export * from './lib/portal-request-failure.util';
export * from './lib/source-health';
export * from './lib/server-storage.contract';
@@ -92,6 +92,11 @@ export interface ExternalPlayerSession {
contentInfo?: PlayerContentInfo;
startedAt: string;
updatedAt: string;
/**
* Order in which sessions began in this app run. Unlike `startedAt` it
* tells apart launches made in the same millisecond.
*/
launchOrder?: number;
/** Raw failure detail; the dock translates `errorCode` instead. */
error?: string;
errorCode?: ExternalPlayerErrorCode;
@@ -0,0 +1,54 @@
/**
* HTTP contract between the PWA and the self-hosted server storage. Routes
* live under `/v1` of the web backend (`/api/v1` behind the Docker nginx).
*/
export const SERVER_STORAGE_API_VERSION = 1;
/** Xtream PWA user-data maps that the server stores per playlist id. */
export const SERVER_USER_STATE_NAMESPACES = [
'xtream-favorites',
'xtream-recent-items',
'xtream-collection-items',
'xtream-playback-positions',
] as const;
export type ServerUserStateNamespace =
(typeof SERVER_USER_STATE_NAMESPACES)[number];
export function isServerUserStateNamespace(
value: string
): value is ServerUserStateNamespace {
return (SERVER_USER_STATE_NAMESPACES as readonly string[]).includes(value);
}
/**
* Document fields that carry an M3U payload. An upsert with
* `keepPayload=1` omits them and the server keeps the stored ones.
*/
export const SERVER_PAYLOAD_FIELDS = ['playlist', 'items', 'header'] as const;
export interface ServerInstanceInfo {
readonly service: 'iptvnator';
readonly apiVersion: number;
/** Stable per database; `null` without server storage. */
readonly instanceId: string | null;
readonly storage: 'server' | 'none';
readonly auth: 'none' | 'token';
}
export interface ServerStorageStatusResponse {
readonly playlists: number;
readonly databaseBytes: number;
readonly databasePath: string;
readonly startedAt: string;
}
export interface ServerUserStateResponse {
readonly entries: Record<ServerUserStateNamespace, Record<string, unknown>>;
}
export interface ServerStorageErrorBody {
readonly message: string;
readonly status: number;
readonly code?: string;
}
-1
View File
@@ -13,7 +13,6 @@ export const maxLinesBaseline = [
'apps/electron-backend/src/app/database/operations/content.operations.ts',
'apps/electron-backend/src/app/database/operations/playlist.operations.ts',
'apps/electron-backend/src/app/events/epg-query.service.ts',
'apps/electron-backend/src/app/events/mpv-session.service.ts',
'apps/electron-backend/src/app/events/vlc-session.service.ts',
'apps/electron-backend/src/app/services/app-update.service.ts',
'apps/electron-backend/src/app/services/embedded-mpv-native.service.ts',
+3
View File
@@ -146,6 +146,9 @@
"@iptvnator/shared/database/schema": [
"libs/shared/database/src/lib/schema.ts"
],
"@iptvnator/shared/database/app-playlist": [
"libs/shared/database/src/lib/app-playlist.ts"
],
"@iptvnator/shared/database/path-utils": [
"libs/shared/database/src/lib/path-utils.ts"
],