mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
fix(portals): stop a remake matching, and let a pin survive its own playlist
Three findings from the latest review pass. `normalizeTitleKeys` strips bracketed segments as tag noise, so "Dune (1984)" normalizes to exactly "dune" — an EXACT match for the 2021 film, ranked above every fuzzy one, with the year never consulted because that tier skipped the gate. Auto-failover could switch the user to the other film entirely. The year is now read out of brackets too, and a stated disagreement rejects the row on either tier. Playback positions are keyed by (playlist, stream), so watching through a pinned alternative stores progress under ITS ids while the page loads the route copy's row. Starting the pin therefore resumed from a position belonging to a different copy — usually zero. It now loads its own. And a pin can point at another copy of the film inside the playlist being viewed, which discovery excludes wholesale: the pinned row was absent from the list, so nothing showed as pinned and Play ignored the preference. The pin is now read before discovery, which keeps that one row. Moves the pin-shaped decisions into the pin module, where the persistence helpers already live. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ed442da3ca
commit
f0c99fac87
12 files changed
+341
-65
No files matched your search
@@ -157,6 +157,31 @@ describe('title-sources.operations', () => {
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a remake whose year is written in brackets', async () => {
|
||||
// "Dune (1984)" normalizes to exactly "dune" — the brackets are
|
||||
// stripped as tag noise — so without reading the year out first it
|
||||
// is an EXACT match for the 2021 film, ranked above every fuzzy
|
||||
// one, and auto-failover would switch to the wrong movie.
|
||||
const { db } = createDbMock([{ ...duneRow, title: 'Dune (1984)' }]);
|
||||
|
||||
await expect(
|
||||
findTitleSources(db, { title: 'Dune', year: 2021 })
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('matches a bracketed year against the same film', async () => {
|
||||
const { db } = createDbMock([{ ...duneRow, title: 'Dune (2021)' }]);
|
||||
|
||||
const matches = await findTitleSources(db, {
|
||||
title: 'Dune',
|
||||
year: 2021,
|
||||
});
|
||||
|
||||
expect(matches).toHaveLength(1);
|
||||
// Reported too, so the row can say which film it is.
|
||||
expect(matches[0].year).toBe(2021);
|
||||
});
|
||||
|
||||
it('rejects a base-tier match whose year contradicts the request', async () => {
|
||||
const { db } = createDbMock([{ ...duneRow, title: 'Dune 1984' }]);
|
||||
|
||||
@@ -267,6 +292,27 @@ describe('title-sources.operations', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps one named copy of the excluded playlist', async () => {
|
||||
// A pin can point at another copy of the film inside the playlist
|
||||
// being viewed. Excluding the playlist wholesale would drop that
|
||||
// row, and the explicit preference would be silently ignored.
|
||||
const { db, all } = createDbMock([
|
||||
{ ...duneRow, xtream_id: 777, content_id: 9 },
|
||||
]);
|
||||
|
||||
const matches = await findTitleSources(db, {
|
||||
title: 'Dune',
|
||||
excludePlaylistId: 'playlist-1',
|
||||
keepContentId: 777,
|
||||
});
|
||||
|
||||
const query = compiledQuery(all);
|
||||
expect(query.sql).toContain('OR c.xtream_id = ?');
|
||||
expect(query.params).toContain(777);
|
||||
// And the TypeScript pass must not throw it away either.
|
||||
expect(matches.map((match) => match.xtreamId)).toEqual([777]);
|
||||
});
|
||||
|
||||
it('leaves the queries unfiltered when no playlist is excluded', async () => {
|
||||
const { db, all } = createDbMock([duneRow]);
|
||||
|
||||
|
||||
@@ -39,6 +39,28 @@ export interface FindTitleSourcesRequest {
|
||||
year?: number | null;
|
||||
/** The playlist the user is already on — never returned as an alternative. */
|
||||
excludePlaylistId?: string | null;
|
||||
/**
|
||||
* One stream id inside that playlist to keep anyway.
|
||||
*
|
||||
* A pin can point at another copy of the film in the playlist the user is
|
||||
* currently on. Excluding the playlist wholesale would drop the pinned row
|
||||
* from the list, and the explicit preference would be silently ignored.
|
||||
*/
|
||||
keepContentId?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A year written in brackets — "Dune (1984)", the commonest catalog form.
|
||||
*
|
||||
* `normalizeTitleKeys` strips bracketed segments wholesale, because they
|
||||
* usually hold quality and language tags. A year written that way therefore
|
||||
* survives in neither key, and both Dunes normalize to exactly "dune" — which
|
||||
* the exact tier accepts without ever consulting the year, so failover could
|
||||
* switch the user to the other film entirely.
|
||||
*/
|
||||
function bracketedYear(title: string): number | null {
|
||||
const match = title.match(/[([{]\s*(19\d{2}|20\d{2})\s*[)\]}]/);
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
function buildFtsMatchQuery(normalizedTitle: string): string {
|
||||
@@ -59,11 +81,16 @@ function buildFtsMatchQuery(normalizedTitle: string): string {
|
||||
* never be read at all.
|
||||
*/
|
||||
function excludePlaylistClause(
|
||||
excludePlaylistId: string | null | undefined
|
||||
excludePlaylistId: string | null | undefined,
|
||||
keepContentId: number | null | undefined
|
||||
): SQL {
|
||||
return excludePlaylistId
|
||||
? sql`AND cat.playlist_id <> ${excludePlaylistId}`
|
||||
: sql``;
|
||||
if (!excludePlaylistId) {
|
||||
return sql``;
|
||||
}
|
||||
|
||||
return typeof keepContentId === 'number'
|
||||
? sql`AND (cat.playlist_id <> ${excludePlaylistId} OR c.xtream_id = ${keepContentId})`
|
||||
: sql`AND cat.playlist_id <> ${excludePlaylistId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,14 +193,19 @@ export async function findTitleSources(
|
||||
return [];
|
||||
}
|
||||
|
||||
// The year the caller knows, falling back to a release tag on the title.
|
||||
const wantedYear = request.year ?? wanted.trailingYear ?? null;
|
||||
// The year the caller knows, falling back to a release tag on the title
|
||||
// in either of the two forms providers write it.
|
||||
const wantedYear =
|
||||
request.year ?? wanted.trailingYear ?? bracketedYear(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);
|
||||
|
||||
const excludePlaylist = excludePlaylistClause(request.excludePlaylistId);
|
||||
const excludePlaylist = excludePlaylistClause(
|
||||
request.excludePlaylistId,
|
||||
request.keepContentId
|
||||
);
|
||||
|
||||
let rows: TitleSourceRow[];
|
||||
try {
|
||||
@@ -196,23 +228,30 @@ export async function findTitleSources(
|
||||
|
||||
for (const row of rows) {
|
||||
// SQL already excluded these; this keeps the guarantee a property of
|
||||
// the function rather than of one WHERE clause.
|
||||
// the function rather than of one WHERE clause — minus the one copy
|
||||
// the caller asked to keep.
|
||||
if (
|
||||
request.excludePlaylistId &&
|
||||
row.playlist_id === request.excludePlaylistId
|
||||
row.playlist_id === request.excludePlaylistId &&
|
||||
row.xtream_id !== request.keepContentId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rowKeys = normalizeTitleKeys(row.title);
|
||||
const rowYear = rowKeys.trailingYear ?? bracketedYear(row.title);
|
||||
// Two films share a title far too often for this to be optional, and
|
||||
// it applies to BOTH tiers: "Dune (1984)" normalizes to exactly the
|
||||
// same string as "Dune", so an exact match says nothing about which
|
||||
// film it is. When each side states a year and they disagree, it is
|
||||
// not the same movie. An unknown year still never blocks.
|
||||
const yearsAgree = titleYearsCompatible(rowYear, wantedYear);
|
||||
const exactMatch =
|
||||
rowKeys.exact !== '' && rowKeys.exact === wanted.exact;
|
||||
rowKeys.exact !== '' &&
|
||||
rowKeys.exact === wanted.exact &&
|
||||
yearsAgree;
|
||||
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);
|
||||
!exactMatch && rowKeys.base === wanted.base && yearsAgree;
|
||||
|
||||
if (!exactMatch && !baseMatch) {
|
||||
continue;
|
||||
@@ -232,7 +271,7 @@ export async function findTitleSources(
|
||||
title: row.title,
|
||||
posterUrl: row.poster_url,
|
||||
matchConfidence: exactMatch ? 'exact' : 'fuzzy',
|
||||
year: rowKeys.trailingYear,
|
||||
year: rowYear,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user