mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -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
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user