mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
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:
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 };
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user