mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
Merge remote-tracking branch 'origin/master' into claude/fervent-cannon-196cb2
This commit is contained in:
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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+6
-71
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`. |
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user