feat(portals): find the same movie in your other playlists

A movie that exists in several imported Xtream playlists now shows a
"Sources N" chip on its detail page and in the player. Switching playlist
mid-film keeps the timecode, a preferred source can be pinned per movie, and
a failed stream offers the alternatives instead of a dead end.

The governing rule is that a guess is never presented as a fact. Every
metadata value carries where it came from — `api` (the provider said so),
`parsed` (inferred from the title) or `probe` (we contacted the stream).
Facts render as plain tags, guesses are prefixed `~` in a warning colour, and
an unknown value renders no tag at all plus a "check" affordance. Ranking and
failover read through `factualOnly()`, so a filename claiming 4K is
structurally unable to outrank a source that was actually reached. A probe
that could not complete reports "unknown", never "unavailable".

Scope is deliberately narrow: Xtream to Xtream, movies only, Electron only.
Stalker never reaches the `content` table and M3U is a JSON blob whose search
forces live content; both are additive later, since the candidate type
already carries all three portal kinds. In the PWA every entry point is gated
off and the chip renders nothing.

Auto-failover is opt-in and off by default. Each source is tried at most once
per session, so it terminates structurally, and the switch is never silent —
the toast names the new playlist, offers an undo, and warns that the dub may
differ only when both sides state an audio track as fact.

Notable details:
- Playlist names are routinely the pasted URL, credentials included. They are
  never rendered raw; a short host-only label is derived instead.
- Quality is derived from pixel width, not height: a 2.39:1 1080p master is
  1920x800, and bucketing that by height would publish "720p" as a fact.
- Switching is a single `inlinePlayback.set()` so the player and engine
  survive and re-seek; the carried position is read before the 15s
  persistence throttle so it does not rewind.
- Sources from one playlist collapse into a group, since the same film often
  appears there several times under different stream ids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-27 08:34:56 +02:00
1 parent e2300bea11
commit 004cb65fe6
101 files changed
+7183 -192

No files matched your search

@@ -50,6 +50,18 @@ const tmdbCacheEntry = {
tmdbId: 603,
payload: '{"id":603}',
};
const vodSourceRequest = {
title: 'The Matrix',
year: 1999,
excludePlaylistId: playlistId,
};
const vodSourceMatchKeys = ['tmdb:603', 'title:the matrix:1999'];
const vodSourcePin = {
matchKey: 'tmdb:603',
playlistId,
contentId: 42,
portalType: 'xtream',
};
export const dbPreloadCases: PreloadInvokeCase[] = [
{
@@ -388,6 +400,30 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
channel: 'DB_MATCH_TITLES',
forwardedArgs: [['The Matrix', 'Inception']],
},
{
method: 'dbFindTitleSources',
args: [vodSourceRequest],
channel: 'DB_FIND_TITLE_SOURCES',
forwardedArgs: [vodSourceRequest],
},
{
method: 'dbGetVodSourcePin',
args: [vodSourceMatchKeys],
channel: 'DB_GET_VOD_SOURCE_PIN',
forwardedArgs: [vodSourceMatchKeys],
},
{
method: 'dbSetVodSourcePin',
args: [vodSourcePin],
channel: 'DB_SET_VOD_SOURCE_PIN',
forwardedArgs: [vodSourcePin],
},
{
method: 'dbClearVodSourcePin',
args: [vodSourceMatchKeys],
channel: 'DB_CLEAR_VOD_SOURCE_PIN',
forwardedArgs: [vodSourceMatchKeys],
},
];
export const epgPreloadCases: PreloadInvokeCase[] = [
@@ -48,6 +48,7 @@ import type {
Settings,
TmdbCacheEntry,
TmdbCacheMediaType,
VodSourcePin,
XtreamCategory,
} from '@iptvnator/shared/interfaces';
import {
@@ -609,6 +610,8 @@ const electronApi: ElectronBridgeApi = {
ipcRenderer.invoke('XTREAM_CANCEL_SESSION', sessionId),
xtreamProbeUrl: (url: string, method?: 'GET' | 'HEAD') =>
ipcRenderer.invoke('XTREAM_PROBE_URL', { url, method }),
probeStreamUrl: (url: string, method?: 'GET' | 'HEAD') =>
ipcRenderer.invoke('STREAM_PROBE_URL', { url, method }),
refreshPlaylist: (payload: PlaylistRefreshPayload) =>
ipcRenderer.invoke('PLAYLIST:REFRESH', payload),
cancelPlaylistRefresh: (operationId: string) =>
@@ -839,6 +842,18 @@ const electronApi: ElectronBridgeApi = {
dbClearTmdbMetadata: () => ipcRenderer.invoke('DB_CLEAR_TMDB_METADATA'),
dbMatchTitles: (titles: string[]) =>
ipcRenderer.invoke('DB_MATCH_TITLES', titles),
// VOD multi-source
dbFindTitleSources: (request: {
title: string;
year?: number | null;
excludePlaylistId?: string | null;
}) => ipcRenderer.invoke('DB_FIND_TITLE_SOURCES', request),
dbGetVodSourcePin: (matchKeys: string[]) =>
ipcRenderer.invoke('DB_GET_VOD_SOURCE_PIN', matchKeys),
dbSetVodSourcePin: (pin: VodSourcePin) =>
ipcRenderer.invoke('DB_SET_VOD_SOURCE_PIN', pin),
dbClearVodSourcePin: (matchKeys: string[]) =>
ipcRenderer.invoke('DB_CLEAR_VOD_SOURCE_PIN', matchKeys),
// Playback Positions
dbSavePlaybackPosition: (
playlistId: string,
@@ -0,0 +1,193 @@
import type { AppDatabase } from '../database.types';
import { findTitleSources } from './title-sources.operations';
function createDbMock(rows: unknown[] = []) {
const all = jest.fn().mockResolvedValue(rows);
return { db: { all } as unknown as AppDatabase, all };
}
function createFailingDbMock() {
const all = jest.fn().mockRejectedValue(new Error('fts syntax'));
return { db: { all } as unknown as AppDatabase, all };
}
const duneRow = {
content_id: 1,
title: 'Dune',
xtream_id: 501,
poster_url: 'https://cdn.example.com/dune.jpg',
category_xtream_id: 12,
playlist_id: 'playlist-1',
playlist_name: 'Portal One',
};
describe('title-sources.operations', () => {
describe('unusable queries', () => {
it('returns nothing for a blank or non-string title', async () => {
const { db, all } = createDbMock([duneRow]);
await expect(findTitleSources(db, { title: '' })).resolves.toEqual(
[]
);
await expect(
findTitleSources(db, { title: ' ' })
).resolves.toEqual([]);
await expect(
findTitleSources(db, {
title: null as unknown as string,
})
).resolves.toEqual([]);
expect(all).not.toHaveBeenCalled();
});
it('returns nothing when no token survives the trigram minimum', async () => {
const { db, all } = createDbMock([duneRow]);
// "up" is two chars — the trigram tokenizer cannot match it, so
// there is no FTS query left to run.
await expect(
findTitleSources(db, { title: 'Up' })
).resolves.toEqual([]);
expect(all).not.toHaveBeenCalled();
});
it('returns nothing instead of throwing when the FTS query fails', async () => {
const { db, all } = createFailingDbMock();
await expect(
findTitleSources(db, { title: 'Weird "Title"' })
).resolves.toEqual([]);
expect(all).toHaveBeenCalledTimes(1);
});
});
describe('match confirmation', () => {
it('maps a confirmed row onto the candidate shape as an exact match', async () => {
const { db } = createDbMock([duneRow]);
await expect(
findTitleSources(db, { title: 'Dune' })
).resolves.toEqual([
{
playlistId: 'playlist-1',
playlistName: 'Portal One',
categoryId: 12,
xtreamId: 501,
title: 'Dune',
posterUrl: 'https://cdn.example.com/dune.jpg',
matchConfidence: 'exact',
year: null,
},
]);
});
it('confirms a year-stripped candidate as a fuzzy match', async () => {
const { db } = createDbMock([{ ...duneRow, title: 'Dune 1984' }]);
const matches = await findTitleSources(db, { title: 'Dune' });
expect(matches).toHaveLength(1);
expect(matches[0].matchConfidence).toBe('fuzzy');
expect(matches[0].year).toBe(1984);
});
it('drops candidates whose normalized title differs', async () => {
const { db } = createDbMock([
{ ...duneRow, title: 'Dune Part Two' },
]);
await expect(
findTitleSources(db, { title: 'Dune' })
).resolves.toEqual([]);
});
it('rejects a base-tier match whose year contradicts the request', async () => {
const { db } = createDbMock([{ ...duneRow, title: 'Dune 1984' }]);
// Same base title, but the user is watching the 2021 film — a
// year-stripped match is only trustworthy when the two sides do
// not actively disagree.
await expect(
findTitleSources(db, { title: 'Dune', year: 2021 })
).resolves.toEqual([]);
});
it('keeps a base-tier match when one side has no known year', async () => {
const { db } = createDbMock([duneRow]);
// Query carries the year tag, the candidate does not: an unknown
// year must never block the match.
const matches = await findTitleSources(db, { title: 'Dune 2021' });
expect(matches).toHaveLength(1);
expect(matches[0].matchConfidence).toBe('fuzzy');
expect(matches[0].year).toBeNull();
});
it('tolerates a one-year drift between the two sides', async () => {
const { db } = createDbMock([{ ...duneRow, title: 'Dune 2021' }]);
const matches = await findTitleSources(db, {
title: 'Dune',
year: 2020,
});
expect(matches).toHaveLength(1);
expect(matches[0].matchConfidence).toBe('fuzzy');
});
});
describe('playlist scoping and duplicates', () => {
it('never returns the playlist the user is already on', async () => {
const { db } = createDbMock([
duneRow,
{
...duneRow,
content_id: 2,
xtream_id: 777,
playlist_id: 'playlist-2',
playlist_name: 'Portal Two',
},
]);
const matches = await findTitleSources(db, {
title: 'Dune',
excludePlaylistId: 'playlist-1',
});
expect(matches).toHaveLength(1);
expect(matches[0].playlistId).toBe('playlist-2');
});
it('collapses the same film listed in several categories', async () => {
const { db } = createDbMock([
duneRow,
{ ...duneRow, content_id: 2, category_xtream_id: 13 },
{ ...duneRow, content_id: 3, category_xtream_id: 14 },
]);
const matches = await findTitleSources(db, { title: 'Dune' });
expect(matches).toHaveLength(1);
expect(matches[0].categoryId).toBe(12);
});
it('keeps the same film when it lives in different playlists', async () => {
const { db } = createDbMock([
duneRow,
{
...duneRow,
playlist_id: 'playlist-2',
playlist_name: 'Portal Two',
},
]);
const matches = await findTitleSources(db, { title: 'Dune' });
expect(matches.map((match) => match.playlistId)).toEqual([
'playlist-1',
'playlist-2',
]);
});
});
});
@@ -0,0 +1,146 @@
import { sql } from 'drizzle-orm';
import {
normalizeTitleKeys,
titleYearsCompatible,
type VodSourceCandidateRow,
} from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
/**
* VOD multi-source discovery: find the SAME movie in the user's other
* playlists.
*
* Deliberately not built on `buildTitleMatchIndex()` — that helper keeps one
* match per title key because the Similar rail only needs "does this exist
* somewhere". Here every copy in every playlist is a distinct, selectable
* source, so all of them are returned.
*
* Scope is Xtream-only: Stalker items never reach the `content` table (they
* would need a live authenticated portal search), and M3U playlists are stored
* as a JSON blob whose search path forces `content_type: 'live'`.
*/
const CANDIDATE_LIMIT = 60;
/** A row as it comes back from SQLite, before match confirmation. */
interface TitleSourceRow {
content_id: number;
title: string;
xtream_id: number;
poster_url: string | null;
category_xtream_id: number;
playlist_id: string;
playlist_name: string;
}
export interface FindTitleSourcesRequest {
title: string;
/** Known release year of the movie being viewed, when available. */
year?: number | null;
/** The playlist the user is already on — never returned as an alternative. */
excludePlaylistId?: string | null;
}
function buildFtsMatchQuery(normalizedTitle: string): string {
const tokens = normalizedTitle
.split(' ')
// The trigram tokenizer cannot match tokens shorter than 3 chars
.filter((token) => token.length >= 3)
.map((token) => `"${token.replace(/"/g, '""')}"`);
return tokens.join(' AND ');
}
export async function findTitleSources(
db: AppDatabase,
request: FindTitleSourcesRequest
): Promise<VodSourceCandidateRow[]> {
const rawTitle = request?.title;
if (typeof rawTitle !== 'string' || rawTitle.trim() === '') {
return [];
}
const wanted = normalizeTitleKeys(rawTitle);
// Search on the year-stripped form so a portal that tags the year and one
// that does not still find each other; the year gate below re-tightens it.
const matchQuery = buildFtsMatchQuery(wanted.base);
if (!wanted.base || !matchQuery) {
return [];
}
// The year the caller knows, falling back to a release tag on the title.
const wantedYear = request.year ?? wanted.trailingYear ?? null;
let rows: TitleSourceRow[];
try {
rows = (await db.all(sql`
SELECT
c.id AS content_id,
c.title AS title,
c.xtream_id AS xtream_id,
c.poster_url AS poster_url,
cat.xtream_id AS category_xtream_id,
cat.playlist_id AS playlist_id,
p.name AS playlist_name
FROM content_title_fts
INNER JOIN content AS c ON c.id = content_title_fts.rowid
INNER JOIN categories AS cat ON c.category_id = cat.id
INNER JOIN playlists AS p ON cat.playlist_id = p.id
WHERE content_title_fts MATCH ${matchQuery}
AND c.type = 'movie'
AND cat.hidden = 0
AND p.type = 'xtream'
ORDER BY rank, c.title
LIMIT ${CANDIDATE_LIMIT}
`)) as TitleSourceRow[];
} catch {
// Malformed FTS query for exotic titles — no sources, not a crash
return [];
}
const matches: VodSourceCandidateRow[] = [];
// One playlist can list the same film in several categories; the user
// thinks of that as one source.
const seen = new Set<string>();
for (const row of rows) {
if (
request.excludePlaylistId &&
row.playlist_id === request.excludePlaylistId
) {
continue;
}
const rowKeys = normalizeTitleKeys(row.title);
const exactMatch =
rowKeys.exact !== '' && rowKeys.exact === wanted.exact;
const baseMatch =
!exactMatch &&
rowKeys.base === wanted.base &&
// A year-stripped match is only trustworthy when the two sides do
// not actively disagree about the year ("Dune" 1984 vs 2021).
titleYearsCompatible(rowKeys.trailingYear, wantedYear);
if (!exactMatch && !baseMatch) {
continue;
}
const dedupeKey = `${row.playlist_id}:${row.xtream_id}`;
if (seen.has(dedupeKey)) {
continue;
}
seen.add(dedupeKey);
matches.push({
playlistId: row.playlist_id,
playlistName: row.playlist_name,
categoryId: row.category_xtream_id,
xtreamId: row.xtream_id,
title: row.title,
posterUrl: row.poster_url,
matchConfidence: exactMatch ? 'exact' : 'fuzzy',
year: rowKeys.trailingYear,
});
}
return matches;
}
@@ -0,0 +1,210 @@
import {
createDbMock,
mockDrizzle,
mockDrizzleOrmModule,
resetDrizzleMocks,
} from './operations.test-helpers';
jest.mock('drizzle-orm', () => mockDrizzleOrmModule());
import * as schema from '@iptvnator/shared/database/schema';
import type { VodSourcePin } from '@iptvnator/shared/interfaces';
import {
clearVodSourcePin,
getVodSourcePin,
setVodSourcePin,
} from './vod-source-pin.operations';
const tmdbRow = {
id: 1,
matchKey: 'tmdb:603',
playlistId: 'playlist-1',
contentId: 501,
portalType: 'xtream' as const,
updatedAt: '2026-07-20T10:00:00.000Z',
};
const titleRow = {
id: 2,
matchKey: 'title:the matrix:1999',
playlistId: 'playlist-2',
contentId: 902,
portalType: 'xtream' as const,
updatedAt: '2026-07-01T10:00:00.000Z',
};
/** `createDbMock` resolves `.values()` directly; pins upsert on top of it. */
function createUpsertDbMock() {
const mock = createDbMock();
const onConflictDoUpdate = jest.fn().mockResolvedValue(undefined);
mock.insertValues.mockReturnValue({ onConflictDoUpdate });
return { ...mock, onConflictDoUpdate };
}
describe('vod-source-pin.operations', () => {
beforeEach(() => {
resetDrizzleMocks();
});
describe('getVodSourcePin', () => {
it('returns null without querying for an empty key list', async () => {
const { db, select } = createDbMock();
await expect(getVodSourcePin(db, [])).resolves.toBeNull();
expect(select).not.toHaveBeenCalled();
});
it('returns null when no pin row exists', async () => {
const { db } = createDbMock([[]]);
await expect(getVodSourcePin(db, ['tmdb:603'])).resolves.toBeNull();
});
it('honours caller key order, not the order SQLite returned', async () => {
// SQLite hands back the older title-keyed row first; the caller
// passes the tmdb key first because it is the more trusted one.
// Reading in row order would let a stale title pin shadow it.
const { db } = createDbMock([[titleRow, tmdbRow]]);
await expect(
getVodSourcePin(db, ['tmdb:603', 'title:the matrix:1999'])
).resolves.toEqual({
matchKey: 'tmdb:603',
playlistId: 'playlist-1',
contentId: 501,
portalType: 'xtream',
updatedAt: '2026-07-20T10:00:00.000Z',
});
});
it('falls back to the next key when the first has no row', async () => {
const { db } = createDbMock([[titleRow]]);
const pin = await getVodSourcePin(db, [
'tmdb:603',
'title:the matrix:1999',
]);
expect(pin?.matchKey).toBe('title:the matrix:1999');
});
it('reports a missing timestamp as undefined', async () => {
const { db } = createDbMock([[{ ...tmdbRow, updatedAt: null }]]);
const pin = await getVodSourcePin(db, ['tmdb:603']);
expect(pin?.updatedAt).toBeUndefined();
});
it('drops junk keys and caps the lookup at eight keys', async () => {
const { db } = createDbMock([[]]);
const keys = [
'',
null as unknown as string,
undefined as unknown as string,
42 as unknown as string,
'k1',
'k2',
'k3',
'k4',
'k5',
'k6',
'k7',
'k8',
'k9',
];
await getVodSourcePin(db, keys);
expect(mockDrizzle.inArray).toHaveBeenCalledWith(
schema.vodSourcePins.matchKey,
['k1', 'k2', 'k3', 'k4', 'k5', 'k6', 'k7', 'k8']
);
});
});
describe('setVodSourcePin', () => {
const pin: VodSourcePin = {
matchKey: 'tmdb:603',
playlistId: 'playlist-1',
contentId: 501,
portalType: 'xtream',
};
it('upserts on the match key', async () => {
const { db, insert, insertValues, onConflictDoUpdate } =
createUpsertDbMock();
await expect(setVodSourcePin(db, pin)).resolves.toEqual({
success: true,
});
expect(insert).toHaveBeenCalledWith(schema.vodSourcePins);
expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({
matchKey: 'tmdb:603',
playlistId: 'playlist-1',
contentId: 501,
portalType: 'xtream',
updatedAt: expect.any(String),
})
);
expect(onConflictDoUpdate).toHaveBeenCalledWith(
expect.objectContaining({
target: schema.vodSourcePins.matchKey,
set: expect.objectContaining({
playlistId: 'playlist-1',
contentId: 501,
portalType: 'xtream',
updatedAt: expect.any(String),
}),
})
);
});
it('writes an ISO timestamp on both the insert and the update', async () => {
const { db, insertValues, onConflictDoUpdate } =
createUpsertDbMock();
await setVodSourcePin(db, pin);
const inserted = insertValues.mock.calls[0][0] as {
updatedAt: string;
};
const updated = onConflictDoUpdate.mock.calls[0][0] as {
set: { updatedAt: string };
};
expect(new Date(inserted.updatedAt).toISOString()).toBe(
inserted.updatedAt
);
expect(updated.set.updatedAt).toBe(inserted.updatedAt);
});
});
describe('clearVodSourcePin', () => {
it('reports failure without touching the database for no keys', async () => {
const { db, deleteFn } = createDbMock();
await expect(clearVodSourcePin(db, [])).resolves.toEqual({
success: false,
});
expect(deleteFn).not.toHaveBeenCalled();
});
it('deletes every alias of the movie in one statement', async () => {
const { db, deleteFn, deleteWhere } = createDbMock();
await expect(
clearVodSourcePin(db, ['tmdb:603', 'title:the matrix:1999'])
).resolves.toEqual({ success: true });
expect(deleteFn).toHaveBeenCalledWith(schema.vodSourcePins);
expect(deleteWhere).toHaveBeenCalledTimes(1);
expect(mockDrizzle.inArray).toHaveBeenCalledWith(
schema.vodSourcePins.matchKey,
['tmdb:603', 'title:the matrix:1999']
);
});
});
});
@@ -0,0 +1,108 @@
import { inArray } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { VodSourcePin } from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
/**
* VOD multi-source pins — the user's per-movie choice of preferred playlist.
*
* Lookups take SEVERAL keys rather than one: a movie pinned before TMDB
* enrichment landed is stored under its title key, and the same movie prefers
* a `tmdb:` key afterwards. Reading both means the id arriving later does not
* orphan an existing pin.
*/
/** Guards against an unbounded IN list if a caller ever passes junk. */
const MAX_KEYS_PER_LOOKUP = 8;
export async function getVodSourcePin(
db: AppDatabase,
matchKeys: string[]
): Promise<VodSourcePin | null> {
const keys = (matchKeys ?? [])
.filter((key) => typeof key === 'string' && key !== '')
.slice(0, MAX_KEYS_PER_LOOKUP);
if (keys.length === 0) {
return null;
}
const rows = await db
.select()
.from(schema.vodSourcePins)
.where(inArray(schema.vodSourcePins.matchKey, keys));
if (rows.length === 0) {
return null;
}
// Callers pass keys most-trusted first (tmdb before title), so honour that
// order rather than whatever SQLite returned.
for (const key of keys) {
const row = rows.find((candidate) => candidate.matchKey === key);
if (row) {
return toPin(row);
}
}
return null;
}
export async function setVodSourcePin(
db: AppDatabase,
pin: VodSourcePin
): Promise<{ success: boolean }> {
const updatedAt = new Date().toISOString();
await db
.insert(schema.vodSourcePins)
.values({
matchKey: pin.matchKey,
playlistId: pin.playlistId,
contentId: pin.contentId,
portalType: pin.portalType,
updatedAt,
})
.onConflictDoUpdate({
target: schema.vodSourcePins.matchKey,
set: {
playlistId: pin.playlistId,
contentId: pin.contentId,
portalType: pin.portalType,
updatedAt,
},
});
return { success: true };
}
export async function clearVodSourcePin(
db: AppDatabase,
matchKeys: string[]
): Promise<{ success: boolean }> {
const keys = (matchKeys ?? [])
.filter((key) => typeof key === 'string' && key !== '')
.slice(0, MAX_KEYS_PER_LOOKUP);
if (keys.length === 0) {
return { success: false };
}
// Clears every alias of the movie, so unpinning is not undone by a stale
// row under the other key form.
await db
.delete(schema.vodSourcePins)
.where(inArray(schema.vodSourcePins.matchKey, keys));
return { success: true };
}
function toPin(row: schema.VodSourcePinRow): VodSourcePin {
return {
matchKey: row.matchKey,
playlistId: row.playlistId,
contentId: row.contentId,
portalType: row.portalType,
updatedAt: row.updatedAt ?? undefined,
};
}
@@ -14,6 +14,7 @@ import './database/playback-position.events';
import './database/playlist.events';
import './database/recently-viewed.events';
import './database/tmdb.events';
import './database/vod-sources.events';
import './database/xtream.events';
export default class DatabaseEvents {
@@ -0,0 +1,23 @@
/**
* VOD multi-source IPC event handlers
* Cross-playlist movie discovery plus the per-movie pinned source.
*/
import type { VodSourcePin } from '@iptvnator/shared/interfaces';
import type { FindTitleSourcesRequest } from '../../database/operations/title-sources.operations';
import { handleWorkerRequest } from './worker-events.utils';
handleWorkerRequest(
'DB_FIND_TITLE_SOURCES',
(request: FindTitleSourcesRequest) => ({ request })
);
handleWorkerRequest('DB_GET_VOD_SOURCE_PIN', (matchKeys: string[]) => ({
matchKeys,
}));
handleWorkerRequest('DB_SET_VOD_SOURCE_PIN', (pin: VodSourcePin) => ({ pin }));
handleWorkerRequest('DB_CLEAR_VOD_SOURCE_PIN', (matchKeys: string[]) => ({
matchKeys,
}));
@@ -31,6 +31,18 @@ const tmdbCacheEntry = {
tmdbId: 603,
payload: '{"id":603}',
};
const vodSourceRequest = {
title: 'The Matrix',
year: 1999,
excludePlaylistId: playlistId,
};
const vodSourceMatchKeys = ['tmdb:603', 'title:the matrix:1999'];
const vodSourcePin = {
matchKey: 'tmdb:603',
playlistId,
contentId: 42,
portalType: 'xtream',
};
export const workerIpcContractCases: WorkerIpcContractCase[] = [
{
@@ -338,4 +350,24 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
args: [['The Matrix', 'Inception']],
payload: { titles: ['The Matrix', 'Inception'] },
},
{
operation: 'DB_FIND_TITLE_SOURCES',
args: [vodSourceRequest],
payload: { request: vodSourceRequest },
},
{
operation: 'DB_GET_VOD_SOURCE_PIN',
args: [vodSourceMatchKeys],
payload: { matchKeys: vodSourceMatchKeys },
},
{
operation: 'DB_SET_VOD_SOURCE_PIN',
args: [vodSourcePin],
payload: { pin: vodSourcePin },
},
{
operation: 'DB_CLEAR_VOD_SOURCE_PIN',
args: [vodSourceMatchKeys],
payload: { matchKeys: vodSourceMatchKeys },
},
];
@@ -45,6 +45,7 @@ async function importDatabaseEventModules(): Promise<void> {
await import('./playlist.events');
await import('./recently-viewed.events');
await import('./tmdb.events');
await import('./vod-sources.events');
await import('./xtream.events');
}
@@ -0,0 +1,181 @@
const registeredHandlers = new Map<string, (...args: unknown[]) => unknown>();
const axiosMock = Object.assign(jest.fn(), {
isAxiosError: jest.fn(),
});
jest.mock('electron', () => ({
ipcMain: {
handle: jest.fn(
(channel: string, handler: (...args: unknown[]) => unknown) => {
registeredHandlers.set(channel, handler);
}
),
},
}));
jest.mock('axios', () => ({
__esModule: true,
default: axiosMock,
}));
describe('stream probe', () => {
beforeEach(async () => {
jest.resetModules();
registeredHandlers.clear();
axiosMock.mockReset();
axiosMock.isAxiosError.mockReset();
const { registerStreamProbeHandlers } = await import('./stream-probe');
registerStreamProbeHandlers();
});
it('registers both the generic and the legacy Xtream channel', () => {
expect(registeredHandlers.get('STREAM_PROBE_URL')).toBeDefined();
// The catchup variant probe still calls the old channel.
expect(registeredHandlers.get('XTREAM_PROBE_URL')).toBeDefined();
});
it('defaults to HEAD and reports latency', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
axiosMock.mockImplementation((config: { url?: string }) =>
Promise.resolve({ status: 200, headers: {}, config })
);
const result = (await probeHandler?.(
{},
{ url: 'http://example.com/movie.mkv' }
)) as { status: number; latencyMs?: number };
expect(result.status).toBe(200);
expect(typeof result.latencyMs).toBe('number');
expect(result.latencyMs).toBeGreaterThanOrEqual(0);
const request = axiosMock.mock.calls[0][0] as {
method?: string;
headers?: Record<string, string>;
responseType?: string;
};
expect(request.method).toBe('HEAD');
// No Range header on HEAD — that is only the GET fallback's job.
expect(request.headers?.['Range']).toBeUndefined();
expect(request.responseType).toBeUndefined();
});
it('follows validated redirects for range GET media probes', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
const destroyProbeBody = jest.fn();
expect(probeHandler).toBeDefined();
axiosMock
.mockImplementationOnce((config: { url?: string }) =>
Promise.resolve({
status: 302,
headers: { location: '/media.ts' },
config,
})
)
.mockImplementationOnce((config: { url?: string }) =>
Promise.resolve({
status: 206,
headers: {},
data: { destroy: destroyProbeBody },
config,
})
);
const result = (await probeHandler?.(
{},
{
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
method: 'GET',
}
)) as { status: number; url: string };
expect(result.status).toBe(206);
expect(result.url).toBe('http://localhost:3211/media.ts');
expect(axiosMock).toHaveBeenCalledTimes(2);
const firstRequest = axiosMock.mock.calls[0][0] as {
headers?: Record<string, string>;
maxRedirects?: number;
method?: string;
responseType?: string;
};
expect(firstRequest.method).toBe('GET');
expect(firstRequest.headers).toEqual(
expect.objectContaining({ Range: 'bytes=0-4095' })
);
expect(firstRequest.headers?.['User-Agent']).not.toBe(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
);
expect(firstRequest.maxRedirects).toBe(0);
expect(firstRequest.responseType).toBe('stream');
// The ranged GET opens a stream nobody reads — it must be released.
expect(destroyProbeBody).toHaveBeenCalledTimes(1);
});
it('rejects private cross-origin redirects for range GET media probes', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
expect(probeHandler).toBeDefined();
axiosMock.mockResolvedValueOnce({
status: 302,
headers: { location: 'http://127.0.0.1/admin' },
config: {
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
},
});
const result = (await probeHandler?.(
{},
{
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
method: 'GET',
}
)) as { error?: string; status: number; url: string };
// A policy rejection carries no latency: it never left the process,
// and callers must be able to tell it apart from a real timing.
expect(result).toEqual({
error: 'URL points to a private or local network address',
status: 0,
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
});
expect(axiosMock).toHaveBeenCalledTimes(1);
});
it('reports a refusal status rather than treating it as unreachable', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
const refusal = Object.assign(new Error('forbidden'), {
response: { status: 403 },
});
axiosMock.mockRejectedValueOnce(refusal);
axiosMock.isAxiosError.mockImplementation(
(error: unknown) => error === refusal
);
const result = (await probeHandler?.(
{},
{ url: 'http://example.com/movie.mkv' }
)) as { status: number; latencyMs?: number };
// 403 answers "is anything there" — it is a fact, not a failure to check.
expect(result.status).toBe(403);
expect(typeof result.latencyMs).toBe('number');
});
it('returns status 0 without latency when no response is obtained', async () => {
const probeHandler = registeredHandlers.get('STREAM_PROBE_URL');
axiosMock.mockRejectedValueOnce(new Error('ETIMEDOUT'));
axiosMock.isAxiosError.mockReturnValue(false);
const result = (await probeHandler?.(
{},
{ url: 'http://example.com/movie.mkv' }
)) as { status: number; url: string; latencyMs?: number };
expect(result).toEqual({
status: 0,
url: 'http://example.com/movie.mkv',
});
});
});
@@ -0,0 +1,123 @@
/**
* Stream reachability probe.
*
* A cheap "is this URL alive" check that runs in the main process because the
* renderer cannot do it: a browser request to an arbitrary IPTV host is
* CORS-blocked, and `no-cors` yields an opaque response where 200, 403 and 404
* are indistinguishable. Electron does not escape this either — `webSecurity`
* stays on — so the honest answer has to come from Node.
*
* Originally inline in `xtream.events.ts` for catchup URL checks; extracted so
* VOD multi-source can reuse it, and so that file stays inside its line budget.
*/
import axios, { AxiosRequestConfig } from 'axios';
import { ipcMain } from 'electron';
import { UnsafeUrlError } from './url-safety';
import { requestWithValidatedRedirects } from '../util/validated-axios';
// Some Xtream panels sit behind Cloudflare (or similar WAFs) configured to
// challenge generic browser-looking User-Agents while allowlisting known
// IPTV player clients. A VLC-style User-Agent reliably passes those checks.
const PROBE_CLIENT_USER_AGENT = 'VLC/3.0.18 LibVLC/3.0.18';
const PROBE_TIMEOUT_MS = 10000;
export interface StreamProbePayload {
url: string;
method?: 'GET' | 'HEAD';
}
export interface StreamProbeResponse {
/**
* The HTTP status, or 0 when no response was obtained at all.
*
* 0 is NOT "offline" — it also covers a timeout and a URL rejected by the
* redirect-safety policy. Callers must render it as "could not check",
* never as a dead source.
*/
status: number;
url: string;
/** Round-trip time; present whenever the request completed either way. */
latencyMs?: number;
error?: string;
}
export async function runStreamProbe(
payload: StreamProbePayload
): Promise<StreamProbeResponse> {
const method = payload.method ?? 'HEAD';
const config: AxiosRequestConfig = {
method,
url: payload.url,
headers: {
'User-Agent': PROBE_CLIENT_USER_AGENT,
// Some servers reject a bare HEAD but answer a tiny ranged GET.
...(method === 'GET' ? { Range: 'bytes=0-4095' } : {}),
},
timeout: PROBE_TIMEOUT_MS,
responseType: method === 'GET' ? 'stream' : undefined,
validateStatus: () => true,
};
// Monotonic: a wall-clock change mid-probe must not yield a negative latency.
const startedAt = performance.now();
const elapsed = () => Math.round(performance.now() - startedAt);
try {
const response = await requestWithValidatedRedirects(
payload.url,
config,
{
allowPrivateNetworkRedirects: false,
allowPrivateNetworks: true,
pinAllowedPrivateNetworkHosts: true,
}
);
// A ranged GET opens a stream we never read — release it immediately.
const responseBody = response.data as
| { destroy?: () => void }
| undefined;
responseBody?.destroy?.();
return {
status: response.status,
url: response.config?.url ?? payload.url,
latencyMs: elapsed(),
};
} catch (error) {
// A refusal still answers the question "is anything there".
if (axios.isAxiosError(error) && error.response) {
return {
status: error.response.status,
url: payload.url,
latencyMs: elapsed(),
};
}
if (error instanceof UnsafeUrlError) {
return {
status: 0,
url: payload.url,
error: error.message,
};
}
return {
status: 0,
url: payload.url,
};
}
}
export function registerStreamProbeHandlers(): void {
ipcMain.handle(
'STREAM_PROBE_URL',
async (_event, payload: StreamProbePayload) => runStreamProbe(payload)
);
// Retained for the Xtream catchup variant probe, which predates this
// module and whose renderer call site still uses the old channel.
ipcMain.handle(
'XTREAM_PROBE_URL',
async (_event, payload: StreamProbePayload) => runStreamProbe(payload)
);
}
@@ -125,85 +125,6 @@ describe('XtreamEvents session cancellation', () => {
expect(userAgent).not.toMatch(/^Mozilla\/5\.0 /);
});
it('follows validated redirects for range GET media probes', async () => {
const probeHandler = registeredHandlers.get('XTREAM_PROBE_URL');
const destroyProbeBody = jest.fn();
expect(probeHandler).toBeDefined();
axiosMock
.mockImplementationOnce((config: { url?: string }) =>
Promise.resolve({
status: 302,
headers: { location: '/media.ts' },
config,
})
)
.mockImplementationOnce((config: { url?: string }) =>
Promise.resolve({
status: 206,
headers: {},
data: { destroy: destroyProbeBody },
config,
})
);
const result = (await probeHandler?.(
{},
{
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
method: 'GET',
}
)) as { status: number; url: string };
expect(result.status).toBe(206);
expect(result.url).toBe('http://localhost:3211/media.ts');
expect(axiosMock).toHaveBeenCalledTimes(2);
const firstRequest = axiosMock.mock.calls[0][0] as {
headers?: Record<string, string>;
maxRedirects?: number;
method?: string;
responseType?: string;
};
expect(firstRequest.method).toBe('GET');
expect(firstRequest.headers).toEqual(
expect.objectContaining({ Range: 'bytes=0-4095' })
);
expect(firstRequest.headers?.['User-Agent']).not.toBe(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
);
expect(firstRequest.maxRedirects).toBe(0);
expect(firstRequest.responseType).toBe('stream');
expect(destroyProbeBody).toHaveBeenCalledTimes(1);
});
it('rejects private cross-origin redirects for range GET media probes', async () => {
const probeHandler = registeredHandlers.get('XTREAM_PROBE_URL');
expect(probeHandler).toBeDefined();
axiosMock.mockResolvedValueOnce({
status: 302,
headers: { location: 'http://127.0.0.1/admin' },
config: {
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
},
});
const result = (await probeHandler?.(
{},
{
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
method: 'GET',
}
)) as { error?: string; status: number; url: string };
expect(result).toEqual({
error: 'URL points to a private or local network address',
status: 0,
url: 'http://localhost:3211/streaming/timeshift.php?stream=45',
});
expect(axiosMock).toHaveBeenCalledTimes(1);
});
it('aborts requests that were registered with only a session id', async () => {
const requestHandler = registeredHandlers.get('XTREAM_REQUEST');
const cancelHandler = registeredHandlers.get(XTREAM_CANCEL_SESSION);
@@ -12,7 +12,6 @@ import {
} from '@iptvnator/shared/interfaces';
import { redactSensitiveData } from '@iptvnator/shared/logging';
import { emitPortalDebugEvent } from './portal-debug.events';
import { UnsafeUrlError } from './url-safety';
import { requestWithValidatedRedirects } from '../util/validated-axios';
// Some Xtream panels sit behind Cloudflare (or similar WAFs) configured to
@@ -296,66 +295,3 @@ type ActiveXtreamRequest = {
const activeXtreamRequests = new Map<string, ActiveXtreamRequest>();
ipcMain.handle(
'XTREAM_PROBE_URL',
async (
_event,
payload: {
url: string;
method?: 'GET' | 'HEAD';
}
) => {
const method = payload.method ?? 'HEAD';
const config: AxiosRequestConfig = {
method,
url: payload.url,
headers: {
'User-Agent': XTREAM_CLIENT_USER_AGENT,
...(method === 'GET' ? { Range: 'bytes=0-4095' } : {}),
},
timeout: 10000,
responseType: method === 'GET' ? 'stream' : undefined,
validateStatus: () => true,
};
try {
const response = await requestWithValidatedRedirects(
payload.url,
config,
{
allowPrivateNetworkRedirects: false,
allowPrivateNetworks: true,
pinAllowedPrivateNetworkHosts: true,
}
);
const responseBody = response.data as
| { destroy?: () => void }
| undefined;
responseBody?.destroy?.();
return {
status: response.status,
url: response.config?.url ?? payload.url,
};
} catch (error) {
if (axios.isAxiosError(error) && error.response) {
return {
status: error.response.status,
url: payload.url,
};
}
if (error instanceof UnsafeUrlError) {
return {
status: 0,
url: payload.url,
error: error.message,
};
}
return {
status: 0,
url: payload.url,
};
}
}
);
@@ -56,6 +56,10 @@ export const DB_WORKER_OPERATIONS = [
'DB_GET_TMDB_CACHE_STATS',
'DB_CLEAR_TMDB_METADATA',
'DB_MATCH_TITLES',
'DB_FIND_TITLE_SOURCES',
'DB_GET_VOD_SOURCE_PIN',
'DB_SET_VOD_SOURCE_PIN',
'DB_CLEAR_VOD_SOURCE_PIN',
] as const;
export type DbWorkerOperation = (typeof DB_WORKER_OPERATIONS)[number];
@@ -4,6 +4,7 @@ import {
} from './database.worker-connection';
import { parentPort } from 'worker_threads';
import type {
VodSourcePin,
XtreamBackupFavoriteItem,
XtreamBackupRecentlyViewedItem,
} from '@iptvnator/shared/interfaces';
@@ -78,12 +79,21 @@ import {
removeRecentItemsBatch,
} from '../database/operations/recently-viewed.operations';
import { matchTitles } from '../database/operations/title-match.operations';
import {
findTitleSources,
type FindTitleSourcesRequest,
} from '../database/operations/title-sources.operations';
import {
clearTmdbMetadata,
getTmdbCacheStats,
getTmdbMetadata,
setTmdbMetadata,
} from '../database/operations/tmdb.operations';
import {
clearVodSourcePin,
getVodSourcePin,
setVodSourcePin,
} from '../database/operations/vod-source-pin.operations';
import {
deleteXtreamContent,
restoreXtreamUserData,
@@ -702,6 +712,28 @@ async function executeRequest(
return matchTitles(db, payload.titles);
}
case 'DB_FIND_TITLE_SOURCES': {
const payload = message.payload as {
request: FindTitleSourcesRequest;
};
return findTitleSources(db, payload.request);
}
case 'DB_GET_VOD_SOURCE_PIN': {
const payload = message.payload as { matchKeys: string[] };
return getVodSourcePin(db, payload.matchKeys);
}
case 'DB_SET_VOD_SOURCE_PIN': {
const payload = message.payload as { pin: VodSourcePin };
return setVodSourcePin(db, payload.pin);
}
case 'DB_CLEAR_VOD_SOURCE_PIN': {
const payload = message.payload as { matchKeys: string[] };
return clearVodSourcePin(db, payload.matchKeys);
}
case 'DB_DELETE_ALL_PLAYLISTS': {
const payload = message.payload as { operationId?: string };
+2
View File
@@ -29,6 +29,7 @@ import { registerStaticHeaderShims } from './app/services/request-header-overrid
import { AppUpdateService } from './app/services/app-update.service';
import { databaseWorkerClient } from './app/services/database-worker-client';
import WindowEvents from './app/events/window.events';
import { registerStreamProbeHandlers } from './app/events/stream-probe';
import XtreamEvents from './app/events/xtream.events';
import { environment } from './environments/environment';
import {
@@ -144,6 +145,7 @@ export default class Main {
SettingsEvents.bootstrapSettingsEvents();
StalkerEvents.bootstrapStalkerEvents();
XtreamEvents.bootstrapXtreamEvents();
registerStreamProbeHandlers();
DatabaseEvents.bootstrapDatabaseEvents();
EpgEvents.bootstrapEpgEvents();
RemoteControlEvents.bootstrapRemoteControlEvents();