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

@@ -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,
};
}