mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
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>
211 lines
6.8 KiB
TypeScript
211 lines
6.8 KiB
TypeScript
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']
|
|
);
|
|
});
|
|
});
|
|
});
|