mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
226 lines
8.1 KiB
TypeScript
226 lines
8.1 KiB
TypeScript
import { extractYear, tmdbPosterUrl } from '@iptvnator/services';
|
|
import type { CatalogTitleLookup, TmdbSearchResult } from '@iptvnator/services';
|
|
import {
|
|
CatalogTitleMatch,
|
|
normalizeTitleKeys,
|
|
titleYearsCompatible,
|
|
} from '@iptvnator/shared/interfaces';
|
|
import {
|
|
DashboardTmdbLookupItem,
|
|
dashboardTmdbLookupKey,
|
|
} from './dashboard-tmdb-lookup.util';
|
|
|
|
/** One recommendation card: TMDB entry + the library match that makes it playable */
|
|
export interface DashboardRecommendationItem {
|
|
tmdbId: number;
|
|
mediaType: 'movie' | 'tv';
|
|
title: string;
|
|
/**
|
|
* TMDB original title when it differs from the localized one — a
|
|
* matching/exclusion alias, never displayed. Catalogs frequently name
|
|
* an item in its original language while the app language localizes
|
|
* the TMDB title, so matching on the localized form alone would hide
|
|
* available recommendations.
|
|
*/
|
|
originalTitle: string | null;
|
|
year: number | null;
|
|
posterUrl: string | null;
|
|
/** vote_average rounded to one decimal, null without votes */
|
|
rating: string | null;
|
|
/** Confident match in an imported Xtream playlist — unmatched entries are dropped */
|
|
match: CatalogTitleMatch;
|
|
/** Display title of the watched item this recommendation came from */
|
|
seedTitle: string;
|
|
}
|
|
|
|
export type RecommendationCandidate = Omit<
|
|
DashboardRecommendationItem,
|
|
'match'
|
|
>;
|
|
|
|
/**
|
|
* The release year an activity row STATES in a metadata field, never one
|
|
* parsed out of its title.
|
|
*
|
|
* The exact tier compares whole normalized titles, so a trailing number
|
|
* there belongs to the NAME: "Blade Runner 2049" is not a 2049 film, and
|
|
* gating that key on a title-derived 2049 would fail to exclude the very
|
|
* film the user just watched (TMDB calls it 2017). Only Stalker rows
|
|
* carry a real date (`info.releasedate`); everything else yields `null`,
|
|
* which keeps the exact tier excluding unconditionally.
|
|
*
|
|
* The base tier is different by construction — its key exists only
|
|
* because a trailing year was stripped off — so it keeps using that year.
|
|
*/
|
|
export function trustedReleaseYear(
|
|
item: DashboardTmdbLookupItem
|
|
): number | null {
|
|
const info = (
|
|
item.stalker_item as { info?: Record<string, unknown> } | undefined
|
|
)?.info;
|
|
const releaseDate = info?.['releasedate'];
|
|
return typeof releaseDate === 'string' ? extractYear(releaseDate) : null;
|
|
}
|
|
|
|
/**
|
|
* What the user has already watched or favorited, in the two forms a
|
|
* recommendation can collide with.
|
|
*/
|
|
export interface ExclusionIndex {
|
|
/**
|
|
* `type:exactNormalizedTitle` → the release years of the rows that
|
|
* produced it. `null` means the row stated no year, which keeps the
|
|
* conservative "exclude anyway" behaviour for that entry.
|
|
*/
|
|
readonly exact: ReadonlyMap<string, readonly (number | null)[]>;
|
|
/**
|
|
* `type:baseNormalizedTitle` → the trailing years stripped from the
|
|
* stored titles that produced it. Only titles that HAD a trailing
|
|
* year appear here, so the base tier is always year-gated.
|
|
*/
|
|
readonly baseYears: ReadonlyMap<string, readonly number[]>;
|
|
}
|
|
|
|
/** Normalized keys for one candidate alias, both matching tiers */
|
|
interface CandidateKeys {
|
|
readonly exact: string;
|
|
readonly base: string;
|
|
}
|
|
|
|
/**
|
|
* Both matching tiers for each of the candidate's aliases — localized
|
|
* title first, original-title alias second.
|
|
*/
|
|
function candidateKeySets(candidate: RecommendationCandidate): CandidateKeys[] {
|
|
const type = candidate.mediaType === 'movie' ? 'movie' : 'series';
|
|
const toKeys = (title: string): CandidateKeys => {
|
|
const keys = normalizeTitleKeys(title);
|
|
return { exact: `${type}:${keys.exact}`, base: `${type}:${keys.base}` };
|
|
};
|
|
|
|
const sets = [toKeys(candidate.title)];
|
|
if (candidate.originalTitle) {
|
|
const alias = toKeys(candidate.originalTitle);
|
|
if (alias.exact !== sets[0].exact) {
|
|
sets.push(alias);
|
|
}
|
|
}
|
|
return sets;
|
|
}
|
|
|
|
/**
|
|
* The candidate as a catalog lookup, localized title first and the
|
|
* original-title alias second.
|
|
*
|
|
* The grouping and ranking themselves are the shared
|
|
* `groupTitleMatchesByKey()` / `pickTitleMatch()`
|
|
* (`@iptvnator/services`) — the Trending rail, the cross-portal Similar
|
|
* rail and the actor pages resolve a batched `DB_MATCH_TITLES` result the
|
|
* same way, and this rail is just the one that also carries an alias.
|
|
* `pickTitleMatch` folds aliases that normalize to one key, so passing
|
|
* both unconditionally is safe.
|
|
*/
|
|
export function candidateLookup(
|
|
candidate: RecommendationCandidate
|
|
): CatalogTitleLookup {
|
|
return {
|
|
type: candidate.mediaType === 'movie' ? 'movie' : 'series',
|
|
titles: candidate.originalTitle
|
|
? [candidate.title, candidate.originalTitle]
|
|
: [candidate.title],
|
|
year: candidate.year,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Whether the user has already watched or favorited this recommendation.
|
|
*
|
|
* Two tiers, because a provider stores whatever the panel named the file.
|
|
* The exact tier catches a stored title that normalizes to the canonical
|
|
* one. The base tier catches the common `"Inception 2010"` shape, whose
|
|
* exact key can never equal TMDB's `"Inception"` — but only when the
|
|
* years agree, so a stored `"Blade Runner 2049"` does not swallow the
|
|
* 1982 film. An unknown year on either side counts as agreeing
|
|
* (`titleYearsCompatible`): recommending something already watched is the
|
|
* worse failure of the two.
|
|
*/
|
|
export function isExcludedCandidate(
|
|
candidate: RecommendationCandidate,
|
|
excluded: ExclusionIndex
|
|
): boolean {
|
|
const yearAgrees = (year: number | null): boolean =>
|
|
year === null || titleYearsCompatible(candidate.year, year);
|
|
|
|
return candidateKeySets(candidate).some(
|
|
({ exact, base }) =>
|
|
(excluded.exact.get(exact) ?? []).some(yearAgrees) ||
|
|
(excluded.baseYears.get(base) ?? []).some((year) =>
|
|
titleYearsCompatible(candidate.year, year)
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Identity of one load: the seed set, the watched/favorited exclusion
|
|
* index, and the imported-playlist set. Including the exclusions means
|
|
* favoriting a recommended title (or new watch history that does not
|
|
* change the top seeds) still invalidates the latch and re-filters the
|
|
* rail; including the catalog means imports and deletions re-run the
|
|
* matching.
|
|
*/
|
|
export function buildLoadKey(
|
|
seeds: readonly DashboardTmdbLookupItem[],
|
|
excluded: ExclusionIndex,
|
|
catalogKey: string
|
|
): string {
|
|
const serializeYears = (
|
|
entries: ReadonlyMap<string, readonly (number | null)[]>
|
|
): string[] =>
|
|
[...entries]
|
|
.map(
|
|
([key, years]) =>
|
|
`${key}=${[...years]
|
|
.map((year) => year ?? '?')
|
|
.sort()
|
|
.join('/')}`
|
|
)
|
|
.sort();
|
|
|
|
const exclusionKey = [
|
|
...serializeYears(excluded.exact),
|
|
...serializeYears(excluded.baseYears),
|
|
].join('|');
|
|
return `${catalogKey}@@${seeds
|
|
.map(dashboardTmdbLookupKey)
|
|
.join('||')}##${exclusionKey}`;
|
|
}
|
|
|
|
export function toCandidates(
|
|
results: readonly TmdbSearchResult[],
|
|
mediaType: 'movie' | 'tv',
|
|
seedTitle: string
|
|
): RecommendationCandidate[] {
|
|
return results
|
|
.map((result) => {
|
|
const title = result.title ?? result.name ?? '';
|
|
const original =
|
|
result.original_title ?? result.original_name ?? '';
|
|
const rating =
|
|
(result.vote_count ?? 0) > 0 && result.vote_average
|
|
? result.vote_average.toFixed(1)
|
|
: null;
|
|
return {
|
|
tmdbId: result.id,
|
|
mediaType,
|
|
title,
|
|
originalTitle:
|
|
original !== '' && original !== title ? original : null,
|
|
year: extractYear(result.release_date ?? result.first_air_date),
|
|
posterUrl: tmdbPosterUrl(result.poster_path),
|
|
rating,
|
|
seedTitle,
|
|
};
|
|
})
|
|
.filter((entry) => entry.tmdbId > 0 && entry.title !== '');
|
|
}
|