From 4cd6522417bb15eb37d23a39b6556ff8aa41838e Mon Sep 17 00:00:00 2001 From: Viren070 Date: Wed, 11 Jun 2025 19:27:43 +0100 Subject: [PATCH] feat: add season/episode matching --- packages/core/src/db/schemas.ts | 10 +- packages/core/src/main.ts | 113 +++- .../frontend/src/components/config-modal.tsx | 5 +- .../frontend/src/components/menu/filters.tsx | 486 +++++++++++------- .../src/components/menu/formatter.tsx | 12 +- .../src/components/menu/miscellaneous.tsx | 18 +- packages/frontend/src/context/userData.tsx | 18 +- 7 files changed, 424 insertions(+), 238 deletions(-) diff --git a/packages/core/src/db/schemas.ts b/packages/core/src/db/schemas.ts index e41c973c..020f0a44 100644 --- a/packages/core/src/db/schemas.ts +++ b/packages/core/src/db/schemas.ts @@ -331,7 +331,15 @@ export const UserDataSchema = z.object({ hideErrors: z.boolean().optional(), hideErrorsForResources: z.array(ResourceSchema).optional(), tmdbAccessToken: z.string().optional(), - strictTitleMatch: z + titleMatching: z + .object({ + mode: z.enum(['exact', 'contains']).optional(), + enabled: z.boolean().optional(), + requestTypes: z.array(z.string()).optional(), + addons: z.array(z.string()).optional(), + }) + .optional(), + seasonEpisodeMatching: z .object({ enabled: z.boolean().optional(), requestTypes: z.array(z.string()).optional(), diff --git a/packages/core/src/main.ts b/packages/core/src/main.ts index 7ef4299f..f3d54c6c 100644 --- a/packages/core/src/main.ts +++ b/packages/core/src/main.ts @@ -1085,7 +1085,8 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => ` details: Record; } const skipReasons: Record = { - strictTitleMatching: { total: 0, details: {} }, + titleMatching: { total: 0, details: {} }, + seasonEpisodeMatching: { total: 0, details: {} }, excludedStreamType: { total: 0, details: {} }, requiredStreamType: { total: 0, details: {} }, excludedResolution: { total: 0, details: {} }, @@ -1117,7 +1118,7 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => ` const isRegexAllowed = FeatureControl.isRegexAllowed(this.userData); let titles: string[] = []; - if (this.userData.strictTitleMatch && TYPES.includes(type as any)) { + if (this.userData.titleMatching && TYPES.includes(type as any)) { try { titles = await new TMDBMetadata().getTitles(id, type as any); logger.info(`Found ${titles.length} titles for ${id}`, { titles }); @@ -1126,8 +1127,15 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => ` } } + const normaliseTitle = (title: string) => { + return title + .replace(/[^\p{L}\p{N}+]/gu, '') + .replace(/\s+/g, '') + .toLowerCase(); + }; + const performTitleMatch = (stream: ParsedStream) => { - const titleMatchingOptions = this.userData.strictTitleMatch; + const titleMatchingOptions = this.userData.titleMatching; if (!titleMatchingOptions || !titleMatchingOptions.enabled) { return true; } @@ -1155,17 +1163,76 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => ` return true; } - return titles.some( - (title) => - title - .replace(/[^\p{L}\p{N}+]/gu, '') - .replace(/\s+/g, '') - .toLowerCase() === - streamTitle - .replace(/[^\p{L}\p{N}+]/gu, '') - .replace(/\s+/g, '') - .toLowerCase() - ); + if (titleMatchingOptions.mode === 'exact') { + // the stream title should be an exact match of a valid title + return titles.some( + (title) => normaliseTitle(title) === normaliseTitle(streamTitle) + ); + } else { + // a valid title should be present somewhere in the stream title + const valid = titles.some((title) => + normaliseTitle(streamTitle).includes(normaliseTitle(title)) + ); + return valid; + } + }; + + const performSeasonEpisodeMatch = (stream: ParsedStream) => { + const seasonEpisodeMatchingOptions = this.userData.seasonEpisodeMatching; + if ( + !seasonEpisodeMatchingOptions || + !seasonEpisodeMatchingOptions.enabled + ) { + return true; + } + + // parse the id to get the season and episode + const seasonEpisodeRegex = /:(\d+):(\d+)$/; + const match = id.match(seasonEpisodeRegex); + + if (!match || !match[1] || !match[2]) { + // only if both season and episode are present, we can filter + return true; + } + + const requestedSeason = parseInt(match[1]); + const requestedEpisode = parseInt(match[2]); + + if ( + seasonEpisodeMatchingOptions.requestTypes?.length && + !seasonEpisodeMatchingOptions.requestTypes.includes(type) + ) { + return true; + } + + if ( + seasonEpisodeMatchingOptions.addons?.length && + !seasonEpisodeMatchingOptions.addons.includes(stream.addon.id!) + ) { + return true; + } + + // is requested season present + if ( + (requestedSeason && + stream.parsedFile?.season && + stream.parsedFile.season !== requestedSeason) || + (stream.parsedFile?.seasons && + !stream.parsedFile.seasons.includes(requestedSeason)) + ) { + return false; + } + + // is requested episode present + if ( + requestedEpisode && + stream.parsedFile?.episode && + stream.parsedFile.episode !== requestedEpisode + ) { + return false; + } + + return true; }; const excludedRegexPatterns = @@ -1731,16 +1798,28 @@ ${errorStreams.length > 0 ? ` ❌ Errors : ${errorStreams.map((s) => ` } if (!performTitleMatch(stream)) { - skipReasons.strictTitleMatching.total++; - skipReasons.strictTitleMatching.details[ + skipReasons.titleMatching.total++; + skipReasons.titleMatching.details[ stream.parsedFile?.title || 'Unknown' ] = - (skipReasons.strictTitleMatching.details[ + (skipReasons.titleMatching.details[ stream.parsedFile?.title || 'Unknown' ] || 0) + 1; return false; } + if (!performSeasonEpisodeMatch(stream)) { + const detail = + stream.parsedFile?.title + + ' ' + + (stream.parsedFile?.seasonEpisode?.join(' x ') || 'Unknown'); + + skipReasons.seasonEpisodeMatching.total++; + skipReasons.seasonEpisodeMatching.details[detail] = + (skipReasons.seasonEpisodeMatching.details[detail] || 0) + 1; + return false; + } + const useMinMax = ( minMax: [number, number] | undefined, defaults: { min: number; max: number } diff --git a/packages/frontend/src/components/config-modal.tsx b/packages/frontend/src/components/config-modal.tsx index 2b68fc69..721830a2 100644 --- a/packages/frontend/src/components/config-modal.tsx +++ b/packages/frontend/src/components/config-modal.tsx @@ -39,7 +39,10 @@ export function ConfigModal({ return; } - setUserData(result.data.config); + setUserData((prev) => ({ + ...prev, + ...result.data!.config, // we just checked that this is not null + })); setUuid(uuid); setPassword(password); setEncryptedPassword(result.data.encryptedPassword); diff --git a/packages/frontend/src/components/menu/filters.tsx b/packages/frontend/src/components/menu/filters.tsx index d204f597..1ce333c7 100644 --- a/packages/frontend/src/components/menu/filters.tsx +++ b/packages/frontend/src/components/menu/filters.tsx @@ -15,6 +15,7 @@ import { FaRegTrashAlt, FaFileExport, FaFileImport, + FaEquals, } from 'react-icons/fa'; import { FaTextSlash } from 'react-icons/fa6'; import { @@ -267,9 +268,9 @@ function Content() { Seeders - - - Strict Title Matching + + + Matching @@ -523,28 +524,28 @@ function Content() { excludedOptions={userData.excludedResolutions || []} includedOptions={userData.includedResolutions || []} onPreferredChange={(preferred) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, preferredResolutions: preferred, - }); + })); }} onRequiredChange={(required) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, requiredResolutions: required, - }); + })); }} onExcludedChange={(excluded) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, excludedResolutions: excluded, - }); + })); }} onIncludedChange={(included) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, includedResolutions: included, - }); + })); }} options={RESOLUTIONS.map((resolution) => ({ name: resolution, @@ -565,28 +566,28 @@ function Content() { excludedOptions={userData.excludedQualities || []} includedOptions={userData.includedQualities || []} onPreferredChange={(preferred) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, preferredQualities: preferred, - }); + })); }} onRequiredChange={(required) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, requiredQualities: required, - }); + })); }} onExcludedChange={(excluded) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, excludedQualities: excluded, - }); + })); }} onIncludedChange={(included) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, includedQualities: included, - }); + })); }} options={QUALITIES.map((quality) => ({ name: quality, @@ -607,28 +608,28 @@ function Content() { excludedOptions={userData.excludedEncodes || []} includedOptions={userData.includedEncodes || []} onPreferredChange={(preferred) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, preferredEncodes: preferred, - }); + })); }} onRequiredChange={(required) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, requiredEncodes: required, - }); + })); }} onExcludedChange={(excluded) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, excludedEncodes: excluded, - }); + })); }} onIncludedChange={(included) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, includedEncodes: included, - }); + })); }} options={ENCODES.map((encode) => ({ name: encode, @@ -649,28 +650,28 @@ function Content() { excludedOptions={userData.excludedStreamTypes || []} includedOptions={userData.includedStreamTypes || []} onPreferredChange={(preferred) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, preferredStreamTypes: preferred, - }); + })); }} onRequiredChange={(required) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, requiredStreamTypes: required, - }); + })); }} onExcludedChange={(excluded) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, excludedStreamTypes: excluded, - }); + })); }} onIncludedChange={(included) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, includedStreamTypes: included, - }); + })); }} options={STREAM_TYPES.map((streamType) => ({ name: streamType, @@ -691,28 +692,28 @@ function Content() { excludedOptions={userData.excludedVisualTags || []} includedOptions={userData.includedVisualTags || []} onPreferredChange={(preferred) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, preferredVisualTags: preferred, - }); + })); }} onRequiredChange={(required) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, requiredVisualTags: required, - }); + })); }} onExcludedChange={(excluded) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, excludedVisualTags: excluded, - }); + })); }} onIncludedChange={(included) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, includedVisualTags: included, - }); + })); }} options={VISUAL_TAGS.map((visualTag) => ({ name: visualTag, @@ -731,28 +732,28 @@ function Content() { excludedOptions={userData.excludedAudioTags || []} includedOptions={userData.includedAudioTags || []} onPreferredChange={(preferred) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, preferredAudioTags: preferred, - }); + })); }} onRequiredChange={(required) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, requiredAudioTags: required, - }); + })); }} onExcludedChange={(excluded) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, excludedAudioTags: excluded, - }); + })); }} onIncludedChange={(included) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, includedAudioTags: included, - }); + })); }} options={AUDIO_TAGS.map((audioTag) => ({ name: audioTag, @@ -771,28 +772,28 @@ function Content() { excludedOptions={userData.excludedAudioChannels || []} includedOptions={userData.includedAudioChannels || []} onPreferredChange={(preferred) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, preferredAudioChannels: preferred, - }); + })); }} onRequiredChange={(required) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, requiredAudioChannels: required, - }); + })); }} onExcludedChange={(excluded) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, excludedAudioChannels: excluded, - }); + })); }} onIncludedChange={(included) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, includedAudioChannels: included, - }); + })); }} options={AUDIO_CHANNELS.map((audioChannel) => ({ name: audioChannel, @@ -811,28 +812,28 @@ function Content() { excludedOptions={userData.excludedLanguages || []} includedOptions={userData.includedLanguages || []} onPreferredChange={(preferred) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, preferredLanguages: preferred, - }); + })); }} onRequiredChange={(required) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, requiredLanguages: required, - }); + })); }} onExcludedChange={(excluded) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, excludedLanguages: excluded, - }); + })); }} onIncludedChange={(included) => { - setUserData({ - ...userData, + setUserData((prev) => ({ + ...prev, includedLanguages: included, - }); + })); }} options={LANGUAGES.map((language) => ({ name: language @@ -952,104 +953,203 @@ function Content() { - + - - - { - setUserData({ - ...userData, - strictTitleMatch: { - ...userData.strictTitleMatch, - enabled: value, - }, - }); - }} - /> + +
+ + { + setUserData((prev) => ({ + ...prev, + titleMatching: { + ...(prev.titleMatching || {}), + enabled: value, + }, + })); + }} + /> - -

- A TMDB access token is required to fetch titles from the - TMDB API. You can get it from your{' '} - - TMDB Account Settings - -

-

- - } - required={!status?.settings.tmdbApiAvailable} - value={userData.tmdbAccessToken} - type="password" - placeholder="Enter your TMDB access token" - onValueChange={(value) => { - setUserData({ - ...userData, - tmdbAccessToken: value, - }); - }} - /> +