From 26ce36bead411015abf9d1464fa748b7fd8c6f49 Mon Sep 17 00:00:00 2001 From: varthe Date: Sun, 21 Sep 2025 21:49:43 +0100 Subject: [PATCH 1/5] fixed deep search ignoring objects --- src/services/filter.ts | 550 +++++++++++++++++------------------------ 1 file changed, 228 insertions(+), 322 deletions(-) diff --git a/src/services/filter.ts b/src/services/filter.ts index 3629bbb..07aeff1 100644 --- a/src/services/filter.ts +++ b/src/services/filter.ts @@ -3,343 +3,249 @@ import { normalizeToArray, isObject, isObjectArray, buildDebugLogMessage } from import type { Webhook, MediaData, Filter, Condition, Keyword, ContentRatings } from "../types" /** - * Optimized function to match values - * Uses Set for faster lookups and early returns for better performance - * - * @param filterValue - The value to match against - * @param dataValue - The data to check - * @param required - If true, requires exact match; if false, allows partial match (include behavior) + * Matches filter values against arbitrary data structures. + * - required=true: exact match against any extracted string + * - required=false: substring match against any extracted string */ export const matchValue = (filterValue: any, dataValue: any, required = false): boolean => { - // Convert filter values to a Set of lowercase strings for faster lookups - const filterValues = new Set(normalizeToArray(filterValue)) - - // Handle object data values - if (isObject(dataValue)) { - // Extract all values from the object for matching - const allValues: string[] = [] - - for (const value of Object.values(dataValue)) { - if (isObjectArray(value)) { - // For arrays of objects, extract all values from each object - for (const item of value as any[]) { - for (const field of Object.values(item)) { - const fieldStr = String(field).toLowerCase() - - // Early return for performance if we find a match - if (required) { - // For required, we need an exact match - if (filterValues.has(fieldStr)) return true - } else { - // For include (default behavior), we check for partial matches - for (const filterVal of filterValues) { - if (fieldStr.includes(filterVal)) return true - } - } - - allValues.push(fieldStr) - } - } - } else { - const valueStr = String(value).toLowerCase() - - // Early return for performance if we find a match - if (required) { - // For required, we need an exact match - if (filterValues.has(valueStr)) return true - } else { - // For include (default behavior), we check for partial matches - for (const filterVal of filterValues) { - if (valueStr.includes(filterVal)) return true - } - } - - allValues.push(valueStr) - } - } - - // If we haven't returned yet, check all collected values - if (required) { - // For required, we need an exact match with any value - return allValues.some((val) => filterValues.has(val)) - } else { - // For include (default behavior), we check for partial matches - return allValues.some((val) => { - for (const filterVal of filterValues) { - if (val.includes(filterVal)) return true - } - return false - }) - } - } - - // Handle array of objects - if (isObjectArray(dataValue)) { - for (const item of dataValue as any[]) { - for (const field of Object.values(item)) { - const fieldStr = String(field).toLowerCase() - - // Early return for performance - if (required) { - // For required, we need an exact match - if (filterValues.has(fieldStr)) return true - } else { - // For include (default behavior), we check for partial matches - for (const filterVal of filterValues) { - if (fieldStr.includes(filterVal)) return true - } - } - } - } - return false - } - - // Handle simple string value - const dataStr = String(dataValue).toLowerCase() - if (required) { - // For required, we need an exact match - return filterValues.has(dataStr) - } else { - // For include (default behavior), we check for partial matches - for (const filterVal of filterValues) { - if (dataStr.includes(filterVal)) return true - } - return false - } + const filterValues = new Set(normalizeToArray(filterValue)) + + const check = (s: string): boolean => { + if (required) return filterValues.has(s) + for (const f of filterValues) if (s.includes(f)) return true + return false + } + + // Object: scan values (including nested objects, arrays of objects, and arrays of primitives) + if (isObject(dataValue)) { + const allValues: string[] = [] + + for (const value of Object.values(dataValue)) { + if (isObject(value)) { + const values = Object.values(value as Record).map((x) => String(x).toLowerCase()) + for (const v of values) if (check(v)) return true + allValues.push(...values) + } else if (isObjectArray(value)) { + for (const item of value as any[]) { + for (const field of Object.values(item)) { + const s = String(field).toLowerCase() + if (check(s)) return true + allValues.push(s) + } + } + } else if (Array.isArray(value)) { + for (const el of value) { + const s = String(el).toLowerCase() + if (check(s)) return true + allValues.push(s) + } + } else { + const s = String(value).toLowerCase() + if (check(s)) return true + allValues.push(s) + } + } + + return allValues.some(check) + } + + // Array of objects + if (isObjectArray(dataValue)) { + for (const item of dataValue as any[]) { + for (const field of Object.values(item)) { + const s = String(field).toLowerCase() + if (check(s)) return true + } + } + return false + } + + // Array of primitives + if (Array.isArray(dataValue)) { + for (const el of dataValue) { + const s = String(el).toLowerCase() + if (check(s)) return true + } + return false + } + + // Primitive + return check(String(dataValue).toLowerCase()) } /** - * Specialized function to match keywords for better performance - * Directly processes the keywords array without using the generic matchValue function - * - * - require: Requires exact match of the keyword - * - include: Allows partial match (default behavior for string/array conditions) - * - exclude: Excludes if there's a partial match + * Specialized keyword matcher: handles require/include/exclude directly on keyword names. */ export const matchKeywords = (keywords: Array, filterCondition: Condition): boolean => { - // Extract keyword names for faster matching - const keywordNames = keywords.map((k) => k.name.toLowerCase()) - - if (typeof filterCondition === "object" && filterCondition !== null) { - // Check for required keywords (exact match) - if ("require" in filterCondition && filterCondition.require) { - const requiredValues = new Set(normalizeToArray(filterCondition.require)) - // For required, we need an exact match with any keyword - const hasRequired = keywordNames.some((name) => requiredValues.has(name)) - if (!hasRequired) return false - } - - // Check for included keywords (partial match) - if ("include" in filterCondition && filterCondition.include) { - const includeValues = normalizeToArray(filterCondition.include) - // For include, we check if any keyword contains any of the include values - const hasIncluded = keywordNames.some((name) => includeValues.some((val) => name.includes(val))) - if (!hasIncluded) return false - } - - // Check for excluded keywords - if ("exclude" in filterCondition && filterCondition.exclude) { - const excludeValues = normalizeToArray(filterCondition.exclude) - // For exclude, we check if any keyword contains any of the exclude values - const hasExcluded = keywordNames.some((name) => excludeValues.some((val) => name.includes(val))) - if (hasExcluded) return false - } - - return true - } - - // Simple string or array condition - behaves like include (partial match) - const filterValues = normalizeToArray(filterCondition) - return keywordNames.some((name) => filterValues.some((val) => name.includes(val))) + const names = keywords.map((k) => k.name.toLowerCase()) + + if (typeof filterCondition === "object" && filterCondition !== null) { + if ("require" in filterCondition && filterCondition.require) { + const req = new Set(normalizeToArray(filterCondition.require)) + if (!names.some((n) => req.has(n))) return false + } + + if ("include" in filterCondition && filterCondition.include) { + const inc = normalizeToArray(filterCondition.include) + if (!names.some((n) => inc.some((v) => n.includes(v)))) return false + } + + if ("exclude" in filterCondition && filterCondition.exclude) { + const exc = normalizeToArray(filterCondition.exclude) + if (names.some((n) => exc.some((v) => n.includes(v)))) return false + } + + return true + } + + const vals = normalizeToArray(filterCondition) + return names.some((n) => vals.some((v) => n.includes(v))) } /** - * Specialized function to match content ratings for better performance - * - * - require: Requires exact match of the rating - * - include: Allows partial match (default behavior for string/array conditions) - * - exclude: Excludes if there's a partial match + * Specialized content rating matcher: handles require/include/exclude on rating strings. */ export const matchContentRatings = (contentRatings: ContentRatings, filterCondition: Condition): boolean => { - if (!contentRatings || !contentRatings.results || contentRatings.results.length === 0) { - return false - } - - // Extract all ratings for faster matching - const ratings: string[] = contentRatings.results.map((r: any) => r.rating.toLowerCase()) - - if (typeof filterCondition === "object" && filterCondition !== null) { - // Check for required ratings (exact match) - if ("require" in filterCondition && filterCondition.require) { - const requiredValues = new Set(normalizeToArray(filterCondition.require)) - // For required, we need an exact match with any rating - const hasRequired = ratings.some((rating: string) => requiredValues.has(rating)) - if (!hasRequired) return false - } - - // Check for included ratings (partial match) - if ("include" in filterCondition && filterCondition.include) { - const includeValues = normalizeToArray(filterCondition.include) - // For include, we check if any rating contains any of the include values - const hasIncluded = ratings.some((rating: string) => includeValues.some((val) => rating.includes(val))) - if (!hasIncluded) return false - } - - // Check for excluded ratings - if ("exclude" in filterCondition && filterCondition.exclude) { - const excludeValues = normalizeToArray(filterCondition.exclude) - // For exclude, we check if any rating contains any of the exclude values - const hasExcluded = ratings.some((rating: string) => excludeValues.some((val) => rating.includes(val))) - if (hasExcluded) return false - } - - return true - } - - // Simple string or array condition - behaves like include (partial match) - const filterValues = normalizeToArray(filterCondition) - return ratings.some((rating: string) => filterValues.some((val) => rating.includes(val))) + if (!contentRatings || !contentRatings.results || contentRatings.results.length === 0) return false + + const ratings: string[] = contentRatings.results.map((r: any) => String(r.rating).toLowerCase()) + + if (typeof filterCondition === "object" && filterCondition !== null) { + if ("require" in filterCondition && filterCondition.require) { + const req = new Set(normalizeToArray(filterCondition.require)) + if (!ratings.some((r) => req.has(r))) return false + } + + if ("include" in filterCondition && filterCondition.include) { + const inc = normalizeToArray(filterCondition.include) + if (!ratings.some((r) => inc.some((v) => r.includes(v)))) return false + } + + if ("exclude" in filterCondition && filterCondition.exclude) { + const exc = normalizeToArray(filterCondition.exclude) + if (ratings.some((r) => exc.some((v) => r.includes(v)))) return false + } + + return true + } + + const vals = normalizeToArray(filterCondition) + return ratings.some((r) => vals.some((v) => r.includes(v))) } /** - * Find matching instances for a webhook based on filters - * Prioritizes checking keywords and content ratings before other properties + * Finds the first filter that matches this webhook + media data and returns its `apply` target. + * Prioritizes keys: keywords, contentRatings, max_seasons. */ export const findInstances = (webhook: Webhook, data: MediaData, filters: Filter[]): string | string[] | null => { - try { - const matchingFilter = filters.find(({ media_type, is_4k, conditions }) => { - if (media_type !== webhook.media.media_type) return false - if (is_4k === false && webhook.media.status !== "PENDING") return false - if (is_4k === true && webhook.media.status4k !== "PENDING") return false - - if (!conditions || Object.keys(conditions).length === 0) return true - - // Prioritize certain keys for better performance - const priorityKeys = ["keywords", "contentRatings", "max_seasons"] - - for (const priorityKey of priorityKeys) { - if (priorityKey in conditions) { - const value = conditions[priorityKey] - - if (priorityKey === "keywords") { - if (!data.keywords) { - logger.debug(`Filter check skipped - Keywords not found in data`) - return false - } - - if (!matchKeywords(data.keywords, value as Condition)) { - logger.debug(`Filter check for keywords did not match.`) - return false - } - - if (logger.isDebugEnabled()) { - logger.debug( - buildDebugLogMessage("Filter check:", { - Field: priorityKey, - "Filter value": value, - "Request value": "Keywords array (matched)", - }) - ) - } - } else if (priorityKey === "contentRatings") { - if (!data.contentRatings) { - logger.debug(`Filter check skipped - Content ratings not found in data`) - return false - } - - // Type assertion to ensure value is treated as Condition - if (!matchContentRatings(data.contentRatings, value as Condition)) { - logger.debug(`Filter check for content ratings did not match.`) - return false - } - - if (logger.isDebugEnabled()) { - logger.debug( - buildDebugLogMessage("Filter check:", { - Field: priorityKey, - "Filter value": value, - "Request value": "Content ratings array (matched)", - }) - ) - } - } else if (priorityKey === "max_seasons" && webhook.extra) { - const requestedSeasons = webhook.extra - .find((item: any) => item.name === "Requested Seasons") - ?.value?.split(",") - - if (requestedSeasons && value && requestedSeasons.length > value) { - return false - } - } - } - } - - for (const [key, value] of Object.entries(conditions)) { - // Skip priority keys that were already processed - if (priorityKeys.includes(key)) { - continue - } - - const requestValue = data[key] || webhook.request?.[key as keyof typeof webhook.request] - if (!requestValue) { - logger.debug(`Filter check skipped - Key "${key}" not found in webhook or data`) - return false - } - - if (logger.isDebugEnabled()) { - logger.debug( - buildDebugLogMessage("Filter check:", { - Field: key, - "Filter value": value, - "Request value": requestValue, - }) - ) - } - - if (typeof value === "object" && value !== null) { - // Check for required values (exact match) - if ("require" in value && value.require) { - if (!matchValue(value.require, requestValue, true)) { - logger.debug(`Filter check for required key "${key}" did not match.`) - return false - } - } - - // Check for included values (partial match) - if ("include" in value && value.include) { - if (!matchValue(value.include, requestValue, false)) { - logger.debug(`Filter check for included key "${key}" did not match.`) - return false - } - } - - // Check for excluded values - if ("exclude" in value && value.exclude) { - if (matchValue(value.exclude, requestValue, false)) { - logger.debug(`Filter check for excluded key "${key}" did not match.`) - return false - } - } - } else { - // Simple string or array condition - behaves like include (partial match) - if (!matchValue(value, requestValue, false)) { - logger.debug(`Filter check for key "${key}" did not match.`) - return false - } - } - } - return true - }) - - if (!matchingFilter) { - logger.info("No matching filter found for the current webhook") - return null - } - - logger.info(`Found matching filter at index ${filters.indexOf(matchingFilter)}`) - return matchingFilter.apply - } catch (error) { - logger.error(`Error finding matching filter: ${error}`) - return null - } + try { + const matchingFilter = filters.find(({ media_type, is_4k, conditions }) => { + if (media_type !== webhook.media.media_type) return false + if (is_4k === false && webhook.media.status !== "PENDING") return false + if (is_4k === true && webhook.media.status4k !== "PENDING") return false + + if (!conditions || Object.keys(conditions).length === 0) return true + + const priorityKeys = ["keywords", "contentRatings", "max_seasons"] + + for (const priorityKey of priorityKeys) { + if (!(priorityKey in conditions)) continue + const value = (conditions as any)[priorityKey] + + if (priorityKey === "keywords") { + if (!data.keywords) return false + if (!matchKeywords(data.keywords, value as Condition)) return false + + if (logger.isDebugEnabled()) { + logger.debug( + buildDebugLogMessage("Filter check:", { + Field: priorityKey, + "Filter value": value, + "Request value": "Keywords array (matched)", + }) + ) + } + } else if (priorityKey === "contentRatings") { + if (!data.contentRatings) return false + if (!matchContentRatings(data.contentRatings, value as Condition)) return false + + if (logger.isDebugEnabled()) { + logger.debug( + buildDebugLogMessage("Filter check:", { + Field: priorityKey, + "Filter value": value, + "Request value": "Content ratings array (matched)", + }) + ) + } + } else if (priorityKey === "max_seasons" && webhook.extra) { + const requestedSeasons = webhook.extra.find((item: any) => item.name === "Requested Seasons")?.value?.split(",") + const max = typeof value === "number" ? value : Number.parseInt(String(value), 10) + if (Number.isFinite(max) && requestedSeasons && requestedSeasons.length > max) return false + } + } + + for (const [key, value] of Object.entries(conditions)) { + if (priorityKeys.includes(key)) continue + + const requestValue = (data as any)[key] ?? (webhook.request ? (webhook.request as any)[key] : undefined) + + if (requestValue === undefined || requestValue === null) { + logger.debug(`Filter check skipped - Key "${key}" not found in webhook or data`) + return false + } + + if (logger.isDebugEnabled()) { + logger.debug( + buildDebugLogMessage("Filter check:", { + Field: key, + "Filter value": value, + "Request value": requestValue, + }) + ) + } + + if (typeof value === "object" && value !== null) { + if ("require" in value && (value as any).require) { + if (!matchValue((value as any).require, requestValue, true)) { + logger.debug(`Filter check for required key "${key}" failed.`) + return false + } + } + + if ("include" in value && (value as any).include) { + if (!matchValue((value as any).include, requestValue, false)) { + logger.debug(`Filter check for included key "${key}" failed.`) + return false + } + } + + if ("exclude" in value && (value as any).exclude) { + if (matchValue((value as any).exclude, requestValue, false)) { + logger.debug(`Filter check for excluded key "${key}" matched an excluded value.`) + return false + } + } + } else { + if (!matchValue(value, requestValue, false)) { + logger.debug(`Filter check for key "${key}" failed.`) + return false + } + } + } + + return true + }) + + if (!matchingFilter) { + logger.info("No matching filter found for the current webhook") + return null + } + + logger.info(`Found matching filter at index ${filters.indexOf(matchingFilter)}`) + return matchingFilter.apply + } catch (error) { + logger.error(`Error finding matching filter: ${error}`) + return null + } } From 9a416c83b0f32115a678598cf582540d8549d233 Mon Sep 17 00:00:00 2001 From: varthe Date: Sun, 21 Sep 2025 22:37:11 +0100 Subject: [PATCH 2/5] update tests and change to typescript --- fields.md | 5 +- filters.test.js | 713 ------------------------------------------------ filters.test.ts | 575 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 577 insertions(+), 716 deletions(-) delete mode 100644 filters.test.js create mode 100644 filters.test.ts diff --git a/fields.md b/fields.md index 7227e50..df90de7 100644 --- a/fields.md +++ b/fields.md @@ -2,7 +2,7 @@ This is a comprehensive list of possible fields that may appear in incoming request data from Overseerr/Jellyseerr and can be used in filters. -Example values for each field can be found in [filters.test.js](https://github.com/varthe/Redirecterr/blob/main/filters.test.js) +Example values for each field can be found in [filters.test.ts](https://github.com/varthe/Redirecterr/blob/main/filters.test.ts) - `requestedBy_email` - `requestedBy_username` @@ -32,8 +32,7 @@ Example values for each field can be found in [filters.test.js](https://github.c - `spokenLanguages` - `productionCompanies` - `productionCountries` -- `networks` +- `networks` - `inProduction` - `numberOfSeasons` / `numberOfEpisodes` - `contentRatings` - diff --git a/filters.test.js b/filters.test.js deleted file mode 100644 index 6c22aee..0000000 --- a/filters.test.js +++ /dev/null @@ -1,713 +0,0 @@ -import { findInstances } from "./src/services/filter" -import { strictEqual } from "assert" - -const movieWebhook = { - notification_type: "MEDIA_AUTO_APPROVED", - media: { - media_type: "movie", - tmdbId: "94605", - tvdbId: "371028", - status: "PENDING", - status4k: "UNKNOWN", - }, - request: { - request_id: "12", - requestedBy_email: "email@email.com", - requestedBy_username: "user2", - requestedBy_avatar: "", - }, - extra: [], -} - -const showWebhook = { - notification_type: "MEDIA_AUTO_APPROVED", - media: { - media_type: "tv", - tmdbId: "94605", - tvdbId: "371028", - status: "PENDING", - status4k: "UNKNOWN", - }, - request: { - request_id: "12", - requestedBy_email: "email@email.com", - requestedBy_username: "user2", - requestedBy_avatar: "", - }, - extra: [{ name: "Requested Seasons", value: "0, 1, 2" }], -} - -const movieGladiator2Data = { - id: 558449, - adult: false, - budget: 310000000, - genres: [ - { id: 28, name: "Action" }, - { id: 12, name: "Adventure" }, - ], - originalLanguage: "en", - originalTitle: "Gladiator II", - popularity: 1333.762, - productionCompanies: [ - { - id: 4, - name: "Paramount Pictures", - originCountry: "US", - logoPath: "/gz66EfNoYPqHTYI4q9UEN4CbHRc.png", - }, - { - id: 14440, - name: "Red Wagon Entertainment", - originCountry: "US", - logoPath: "/5QbaGiuxc91D6qf75JZGX6OKXoU.png", - }, - { - id: 49325, - name: "Parkes+MacDonald Image Nation", - originCountry: "US", - logoPath: "/R05WCoCJcPWGSDaKaYgx3AeVuR.png", - }, - { - id: 221347, - name: "Scott Free Productions", - originCountry: "US", - logoPath: "/6Ry6uNBaa0IbbSs1XYIgX5DkA9r.png", - }, - ], - productionCountries: [{ iso_3166_1: "US", name: "United States of America" }], - releaseDate: "2024-11-13", - revenue: 0, - spokenLanguages: [{ english_name: "English", iso_639_1: "en", name: "English" }], - status: "Released", - title: "Gladiator II", - video: false, - voteAverage: 7.315, - voteCount: 65, - backdropPath: "/8mjYwWT50GkRrrRdyHzJorfEfcl.jpg", - homepage: "https://www.gladiator.movie", - imdbId: "tt9218128", - runtime: 148, - tagline: "Prepare to be entertained.", - collection: { - id: 1069584, - name: "Gladiator Collection", - posterPath: "/r7uyUOB6fmmPumWwHiV7Hn2kUbL.jpg", - backdropPath: "/eCWJHiezqeSvn0aEt1kPM6Lmlhe.jpg", - }, - externalIds: { - facebookId: "GladiatorMovie", - imdbId: "tt9218128", - instagramId: "gladiatormovie", - twitterId: "GladiatorMovie", - }, - mediaInfo: { - downloadStatus: [], - downloadStatus4k: [], - id: 8, - mediaType: "movie", - tmdbId: 558449, - tvdbId: null, - imdbId: null, - status: 3, - status4k: 1, - createdAt: "2024-11-14T08:19:49.000Z", - updatedAt: "2024-11-14T08:19:49.000Z", - lastSeasonChange: "2024-11-14T08:19:49.000Z", - mediaAddedAt: null, - serviceId: 0, - serviceId4k: null, - externalServiceId: 2, - externalServiceId4k: null, - externalServiceSlug: "558449", - externalServiceSlug4k: null, - ratingKey: null, - ratingKey4k: null, - requests: [[Object]], - issues: [], - seasons: [], - serviceUrl: "http://radarr:7878/movie/558449", - }, - watchProviders: [], - keywords: [ - { id: 6917, name: "epic" }, - { id: 1394, name: "gladiator" }, - { id: 1405, name: "roman empire" }, - { id: 5049, name: "ancient rome" }, - { id: 9663, name: "sequel" }, - { id: 307212, name: "evil tyrant" }, - { id: 317728, name: "sword and sandal" }, - { id: 320529, name: "sword fighting" }, - { id: 321763, name: "second part" }, - ], -} - -const showArcaneData = { - createdBy: [ - { - id: 2000007, - credit_id: "62d5e468c92c5d004f0d1201", - name: "Christian Linke", - original_name: "Christian Linke", - gender: 2, - profile_path: null, - }, - { - id: 3299121, - credit_id: "62d5e46e72c13e062e7196aa", - name: "Alex Yee", - original_name: "Alex Yee", - gender: 2, - profile_path: null, - }, - ], - episodeRunTime: [], - firstAirDate: "2021-11-06", - genres: [ - { id: 16, name: "Animation" }, - { id: 10765, name: "Sci-Fi & Fantasy" }, - { id: 10759, name: "Action & Adventure" }, - { id: 9648, name: "Mystery" }, - ], - relatedVideos: [ - { - site: "YouTube", - key: "3Svs_hl897c", - name: "Final Trailer", - size: 1080, - type: "Trailer", - url: "https://www.youtube.com/watch?v=3Svs_hl897c", - }, - { - site: "YouTube", - key: "fXmAurh012s", - name: "Official Trailer", - size: 1080, - type: "Trailer", - url: "https://www.youtube.com/watch?v=fXmAurh012s", - }, - ], - homepage: "https://arcane.com", - id: 94605, - inProduction: true, - languages: ["en"], - lastAirDate: "2024-11-09", - name: "Arcane", - networks: [ - { - id: 213, - name: "Netflix", - originCountry: "", - logoPath: "/wwemzKWzjKYJFfCeiB57q3r4Bcm.png", - }, - ], - numberOfEpisodes: 18, - numberOfSeasons: 2, - originCountry: ["US"], - originalLanguage: "en", - originalName: "Arcane", - tagline: "The hunt is on.", - overview: - "Amid the stark discord of twin cities Piltover and Zaun, two sisters fight on rival sides of a war between magic technologies and clashing convictions.", - popularity: 1437.972, - productionCompanies: [ - { - id: 99496, - name: "Fortiche Production", - originCountry: "FR", - logoPath: "/6WTCdsmIH6qR2zFVHlqjpIZhD5A.png", - }, - { - id: 124172, - name: "Riot Games", - originCountry: "US", - logoPath: "/sBlhznEktXKBqC87Bsfwpo1YbYR.png", - }, - ], - productionCountries: [ - { iso_3166_1: "FR", name: "France" }, - { iso_3166_1: "US", name: "United States of America" }, - ], - contentRatings: { - results: [ - { descriptors: [], iso_3166_1: "US", rating: "TV-14" }, - { descriptors: [], iso_3166_1: "AU", rating: "MA 15+" }, - { descriptors: [], iso_3166_1: "RU", rating: "18+" }, - { descriptors: [], iso_3166_1: "DE", rating: "16" }, - { descriptors: [], iso_3166_1: "GB", rating: "15" }, - { descriptors: [], iso_3166_1: "BR", rating: "16" }, - { descriptors: [], iso_3166_1: "NL", rating: "12" }, - { descriptors: [], iso_3166_1: "PT", rating: "16" }, - { descriptors: [], iso_3166_1: "ES", rating: "16" }, - ], - }, - status: "Returning Series", - type: "Scripted", - voteAverage: 8.8, - voteCount: 4141, - backdropPath: "/q8eejQcg1bAqImEV8jh8RtBD4uH.jpg", - posterPath: "/abf8tHznhSvl9BAElD2cQeRr7do.jpg", - externalIds: { - facebookId: "arcaneshow", - imdbId: "tt11126994", - instagramId: "arcaneshow", - tvdbId: 371028, - twitterId: "arcaneshow", - }, - keywords: [ - { id: 2343, name: "magic" }, - { id: 5248, name: "female friendship" }, - { id: 7947, name: "war of independence" }, - { id: 14643, name: "battle" }, - { id: 41645, name: "based on video game" }, - { id: 146946, name: "death in family" }, - { id: 161919, name: "adult animation" }, - { id: 192913, name: "warrior" }, - { id: 193319, name: "broken family" }, - { id: 273967, name: "war" }, - { id: 288793, name: "power" }, - { id: 311315, name: "dramatic" }, - { id: 321464, name: "intense" }, - ], -} - -const sampleFilters = [ - { - media_type: "movie", - conditions: { - originalLanguage: "en", - genres: "Action", - keywords: { exclude: "anime" }, - }, - apply: "radarr", - }, - { - media_type: "movie", - conditions: { - keywords: "epic", - genres: ["Adventure", "Drama"], - originalLanguage: "pl", - }, - apply: "radarr2", - }, - { - media_type: "movie", - conditions: { - contentRatings: "16", - }, - apply: "radarr3", - }, - { - media_type: "tv", - conditions: { - originalLanguage: "en", - keywords: { exclude: ["intense", "dramatic", "power"] }, - genres: "Animation", - }, - apply: "sonarr", - }, - { - media_type: "tv", - conditions: { - genres: ["Sci-Fi & Fantasy", "Animation"], - keywords: ["power", "dramatic"], - }, - apply: "sonarr2", - }, -] - -// No need for server.close() since we're not starting a server in this test file - -// Add new filters for testing require, include, and exclude functionality -// Based on the test results, we need to adjust our expectations to match the actual behavior -const additionalFilters = [ - { - media_type: "movie", - conditions: { - genres: { require: ["Action"] }, // Ensure exact match - keywords: { include: "epic" }, - }, - apply: "require-include-test", - }, - { - media_type: "movie", - conditions: { - genres: { require: ["Action", "Adventure"] }, // Ensure exact match for both - keywords: { exclude: ["horror", "anime"] }, - }, - apply: "require-exclude-test", - }, - { - media_type: "tv", - conditions: { - genres: { include: "Animation" }, - keywords: { require: "magic" }, - }, - apply: "include-require-test", - }, - { - media_type: "tv", - conditions: { - genres: { include: ["Animation", "Mystery"] }, - keywords: { exclude: "horror" }, - originalLanguage: { require: "en" }, - }, - apply: "mixed-conditions-test", - }, -] - -describe("Filter Matching Tests", () => { - // Based on the test results, we need to adjust our expectations - // The original tests were written for a different implementation - - it("Test movie filter with exclude keyword", () => { - // Create a simplified filter that should match - const simpleFilter = [ - { - media_type: "movie", - conditions: { - originalLanguage: "en", - }, - apply: "simple-test", - }, - ] - const result = findInstances(movieWebhook, movieGladiator2Data, simpleFilter) - strictEqual(result, "simple-test", "Expected filter to match for 'en' language") - }) - - it("Exclude movie with keyword", () => { - const data = { ...movieGladiator2Data, keywords: [{ id: 12345, name: "anime" }] } - // Create a filter with exclude condition - const excludeFilter = [ - { - media_type: "movie", - conditions: { - keywords: { exclude: "anime" }, - }, - apply: "exclude-test", - }, - ] - const result = findInstances(movieWebhook, data, excludeFilter) - strictEqual(result, null, "Expected filter to exclude due to 'anime' keyword") - }) - - it("Match show with language only", () => { - // Create a simplified filter with just language - const simpleFilter = [ - { - media_type: "tv", - conditions: { - originalLanguage: "en", - }, - apply: "tv-test", - }, - ] - const result = findInstances(showWebhook, showArcaneData, simpleFilter) - strictEqual(result, "tv-test", "Expected filter to match for 'en' language") - }) - - it("Test keyword exclusion", () => { - // Create a filter with exclude condition - const excludeFilter = [ - { - media_type: "tv", - conditions: { - keywords: { exclude: "intense" }, - }, - apply: "exclude-test", - }, - ] - // Create data with the excluded keyword - const data = { - ...showArcaneData, - keywords: [{ id: 321464, name: "intense" }], - } - const result = findInstances(showWebhook, data, excludeFilter) - strictEqual(result, null, "Expected filter to exclude due to 'intense' keyword") - }) - - it("Test keyword matching", () => { - // Create a filter with just a keyword condition - const keywordFilter = [ - { - media_type: "tv", - conditions: { - keywords: "power", - }, - apply: "keyword-test", - }, - ] - const data = { - ...showArcaneData, - keywords: [{ id: 288793, name: "power" }], - } - const result = findInstances(showWebhook, data, keywordFilter) - // The test logs show this actually matches - strictEqual(result, "keyword-test", "Expected match with simple keyword condition") - }) - - it("Match movie based on age rating", () => { - const data = { - ...showArcaneData, - contentRatings: { - results: [ - { descriptors: [], iso_3166_1: "US", rating: "TV-14" }, - { descriptors: [], iso_3166_1: "AU", rating: "MA 15+" }, - { descriptors: [], iso_3166_1: "RU", rating: "18+" }, - { descriptors: [], iso_3166_1: "DE", rating: "16" }, - { descriptors: [], iso_3166_1: "GB", rating: "15" }, - { descriptors: [], iso_3166_1: "BR", rating: "16" }, - { descriptors: [], iso_3166_1: "NL", rating: "12" }, - { descriptors: [], iso_3166_1: "PT", rating: "16" }, - { descriptors: [], iso_3166_1: "ES", rating: "16" }, - ], - }, - originalLanguage: "jp", - } - const result = findInstances(movieWebhook, data, sampleFilters) - strictEqual(result, "radarr3", "Expected filter to match for '16' content rating") - }) - - it("Handle non-matching cases gracefully", () => { - const data = { ...movieGladiator2Data, originalLanguage: "fr" } - const result = findInstances(movieWebhook, data, sampleFilters) - strictEqual(result, null, "Expected no filter match due to non-matching language") - }) - - it("Match a complex filter with mixed types (strings, arrays)", () => { - const data = { - ...showArcaneData, - originalLanguage: "en", - genres: [ - { id: 16, name: "Animation" }, - { id: 10759, name: "Action & Adventure" }, - ], - keywords: [ - { id: 311315, name: "dramatic" }, - { id: 273967, name: "war" }, - ], - } - const result = findInstances(showWebhook, data, sampleFilters) - strictEqual(result, "sonarr2", "Expected filter to match for mixed types with genres and keywords") - }) - - // New tests for require, include, and exclude functionality - describe("Advanced Filter Conditions Tests", () => { - // Based on the test results, we need to adjust our expectations - // The tests show that the current implementation has specific behavior for require/include/exclude - - it("Test require condition with missing genre", () => { - const data = { - ...movieGladiator2Data, - genres: [{ id: 12, name: "Adventure" }], // Missing Action genre - } - const result = findInstances(movieWebhook, data, additionalFilters) - strictEqual(result, null, "Expected no match when required genre is missing") - }) - - it("Test exclude condition with excluded keyword present", () => { - const data = { - ...movieGladiator2Data, - keywords: [ - ...movieGladiator2Data.keywords, - { id: 9999, name: "horror" }, // Add excluded keyword - ], - } - const result = findInstances(movieWebhook, data, additionalFilters) - strictEqual(result, null, "Expected no match when excluded keyword is present") - }) - - it("Test include condition with partial match", () => { - // Create a filter with just an include condition - const includeFilter = [ - { - media_type: "movie", - conditions: { - keywords: { include: "epic" }, - }, - apply: "include-test", - }, - ] - const result = findInstances(movieWebhook, movieGladiator2Data, includeFilter) - strictEqual(result, "include-test", "Expected match with included keyword") - }) - - // Based on the test results, it seems the require condition is not working as expected - // Let's adjust our tests to check what's actually happening - - it("Test simple string condition", () => { - // Create a filter with a simple string condition - const simpleFilter = [ - { - media_type: "movie", - conditions: { - genres: "Action", - }, - apply: "simple-genre-test", - }, - ] - const result = findInstances(movieWebhook, movieGladiator2Data, simpleFilter) - // The test logs show this doesn't match, so let's expect null - strictEqual(result, null, "Expected no match with simple genre condition") - }) - - it("Test array condition", () => { - // Create a filter with an array condition - const arrayFilter = [ - { - media_type: "movie", - conditions: { - genres: ["Action", "Adventure"], - }, - apply: "array-test", - }, - ] - const result = findInstances(movieWebhook, movieGladiator2Data, arrayFilter) - // The test logs show this actually matches - strictEqual(result, "array-test", "Expected match with array genre condition") - }) - - it("Test object condition with include", () => { - // Create a filter with an object condition using include - const includeFilter = [ - { - media_type: "movie", - conditions: { - genres: { include: "Action" }, - }, - apply: "include-test", - }, - ] - const result = findInstances(movieWebhook, movieGladiator2Data, includeFilter) - // The test logs show this doesn't match, so let's expect null - strictEqual(result, null, "Expected no match with include genre condition") - }) - - // Additional tests for require, include, and exclude - it("Test multiple exclude conditions", () => { - const multiExcludeFilter = [ - { - media_type: "movie", - conditions: { - keywords: { exclude: ["horror", "anime", "romance"] }, - }, - apply: "multi-exclude-test", - }, - ] - const result = findInstances(movieWebhook, movieGladiator2Data, multiExcludeFilter) - strictEqual(result, "multi-exclude-test", "Expected match when multiple excluded keywords are not present") - }) - - it("Test exclude with one matching condition", () => { - const excludeFilter = [ - { - media_type: "movie", - conditions: { - keywords: { exclude: ["epic", "horror"] }, // "epic" is present in the data - }, - apply: "exclude-test", - }, - ] - const result = findInstances(movieWebhook, movieGladiator2Data, excludeFilter) - strictEqual(result, null, "Expected no match when one excluded keyword is present") - }) - - // Tests for combinations of include, require, and exclude in a single condition - it("Test keywords with include, require, and exclude in one condition", () => { - const combinedFilter = [ - { - media_type: "movie", - conditions: { - keywords: { - include: "gladiator", - require: "epic", - exclude: "horror", - }, - }, - apply: "combined-keywords-test", - }, - ] - const result = findInstances(movieWebhook, movieGladiator2Data, combinedFilter) - // The test logs show this actually matches - strictEqual(result, "combined-keywords-test", "Expected match for combined condition types") - }) - - it("Test multiple condition types across different fields", () => { - const multiFieldFilter = [ - { - media_type: "movie", - conditions: { - keywords: { include: "epic" }, - genres: { require: "Action" }, - originalLanguage: "en", - }, - apply: "multi-field-test", - }, - ] - const result = findInstances(movieWebhook, movieGladiator2Data, multiFieldFilter) - // Based on the implementation, we need to check the actual behavior - strictEqual(result, null, "Expected behavior for multiple condition types across fields") - }) - - it("Test complex condition with all types", () => { - // Create a more complex test case with custom data - const complexData = { - ...movieGladiator2Data, - genres: [{ id: 28, name: "Action" }], // Only Action genre - keywords: [ - { id: 6917, name: "epic" }, - { id: 1394, name: "gladiator" }, - ], - } - - const complexFilter = [ - { - media_type: "movie", - conditions: { - keywords: { - include: "epic", - exclude: "horror", - }, - genres: { require: "Action" }, - originalLanguage: "en", - }, - apply: "complex-test", - }, - ] - - const result = findInstances(movieWebhook, complexData, complexFilter) - // Based on the logs, this doesn't match due to the require condition - strictEqual(result, null, "Expected no match for complex condition with all types") - }) - - it("Test complex condition with negative case", () => { - // Create a test case that should not match - const complexData = { - ...movieGladiator2Data, - genres: [{ id: 28, name: "Action" }], // Only Action genre - keywords: [ - { id: 6917, name: "epic" }, - { id: 9999, name: "horror" }, // This should trigger the exclude condition - ], - } - - const complexFilter = [ - { - media_type: "movie", - conditions: { - keywords: { - include: "epic", - exclude: "horror", // This should prevent a match - }, - genres: { require: "Action" }, - originalLanguage: "en", - }, - apply: "complex-test", - }, - ] - - const result = findInstances(movieWebhook, complexData, complexFilter) - // This should not match due to the excluded keyword - strictEqual(result, null, "Expected no match when excluded keyword is present") - }) - }) -}) diff --git a/filters.test.ts b/filters.test.ts new file mode 100644 index 0000000..dda49b1 --- /dev/null +++ b/filters.test.ts @@ -0,0 +1,575 @@ +import { describe, it } from "bun:test" +import { strictEqual } from "assert" +import { findInstances } from "./src/services/filter" + +// --- Webhook and media data --- + +const movieWebhook = { + notification_type: "MEDIA_AUTO_APPROVED", + media: { + media_type: "movie", + tmdbId: "94605", + tvdbId: "371028", + status: "PENDING", + status4k: "UNKNOWN", + }, + request: { + request_id: "12", + requestedBy_email: "email@email.com", + requestedBy_username: "user2", + requestedBy_avatar: "", + }, + extra: [], +} as const + +const showWebhook = { + notification_type: "MEDIA_AUTO_APPROVED", + media: { + media_type: "tv", + tmdbId: "94605", + tvdbId: "371028", + status: "PENDING", + status4k: "UNKNOWN", + }, + request: { + request_id: "12", + requestedBy_email: "email@email.com", + requestedBy_username: "user2", + requestedBy_avatar: "", + }, + extra: [{ name: "Requested Seasons", value: "0, 1, 2" }], +} as const + +const movieGladiator2Data = { + id: 558449, + adult: false, + budget: 310000000, + genres: [ + { id: 28, name: "Action" }, + { id: 12, name: "Adventure" }, + ], + originalLanguage: "en", + originalTitle: "Gladiator II", + popularity: 1333.762, + productionCompanies: [ + { + id: 4, + name: "Paramount Pictures", + originCountry: "US", + logoPath: "/gz66EfNoYPqHTYI4q9UEN4CbHRc.png", + }, + { + id: 14440, + name: "Red Wagon Entertainment", + originCountry: "US", + logoPath: "/5QbaGiuxc91D6qf75JZGX6OKXoU.png", + }, + { + id: 49325, + name: "Parkes+MacDonald Image Nation", + originCountry: "US", + logoPath: "/R05WCoCJcPWGSDaKaYgx3AeVuR.png", + }, + { + id: 221347, + name: "Scott Free Productions", + originCountry: "US", + logoPath: "/6Ry6uNBaa0IbbSs1XYIgX5DkA9r.png", + }, + ], + productionCountries: [{ iso_3166_1: "US", name: "United States of America" }], + releaseDate: "2024-11-13", + revenue: 0, + spokenLanguages: [{ english_name: "English", iso_639_1: "en", name: "English" }], + status: "Released", + title: "Gladiator II", + video: false, + voteAverage: 7.315, + voteCount: 65, + backdropPath: "/8mjYwWT50GkRrrRdyHzJorfEfcl.jpg", + homepage: "https://www.gladiator.movie", + imdbId: "tt9218128", + runtime: 148, + tagline: "Prepare to be entertained.", + collection: { + id: 1069584, + name: "Gladiator Collection", + posterPath: "/r7uyUOB6fmmPumWwHiV7Hn2kUbL.jpg", + backdropPath: "/eCWJHiezqeSvn0aEt1kPM6Lmlhe.jpg", + }, + externalIds: { + facebookId: "GladiatorMovie", + imdbId: "tt9218128", + instagramId: "gladiatormovie", + twitterId: "GladiatorMovie", + }, + mediaInfo: { + downloadStatus: [], + downloadStatus4k: [], + id: 8, + mediaType: "movie", + tmdbId: 558449, + tvdbId: null, + imdbId: null, + status: 3, + status4k: 1, + createdAt: "2024-11-14T08:19:49.000Z", + updatedAt: "2024-11-14T08:19:49.000Z", + lastSeasonChange: "2024-11-14T08:19:49.000Z", + mediaAddedAt: null, + serviceId: 0, + serviceId4k: null, + externalServiceId: 2, + externalServiceId4k: null, + externalServiceSlug: "558449", + externalServiceSlug4k: null, + ratingKey: null, + ratingKey4k: null, + requests: [] as any, + issues: [], + seasons: [], + serviceUrl: "http://radarr:7878/movie/558449", + }, + watchProviders: [], + keywords: [ + { id: 6917, name: "epic" }, + { id: 1394, name: "gladiator" }, + { id: 1405, name: "roman empire" }, + { id: 5049, name: "ancient rome" }, + { id: 9663, name: "sequel" }, + { id: 307212, name: "evil tyrant" }, + { id: 317728, name: "sword and sandal" }, + { id: 320529, name: "sword fighting" }, + { id: 321763, name: "second part" }, + ], +} as const + +const showArcaneData = { + createdBy: [ + { + id: 2000007, + credit_id: "62d5e468c92c5d004f0d1201", + name: "Christian Linke", + original_name: "Christian Linke", + gender: 2, + profile_path: null, + }, + { + id: 3299121, + credit_id: "62d5e46e72c13e062e7196aa", + name: "Alex Yee", + original_name: "Alex Yee", + gender: 2, + profile_path: null, + }, + ], + episodeRunTime: [], + firstAirDate: "2021-11-06", + genres: [ + { id: 16, name: "Animation" }, + { id: 10765, name: "Sci-Fi & Fantasy" }, + { id: 10759, name: "Action & Adventure" }, + { id: 9648, name: "Mystery" }, + ], + relatedVideos: [ + { + site: "YouTube", + key: "3Svs_hl897c", + name: "Final Trailer", + size: 1080, + type: "Trailer", + url: "https://www.youtube.com/watch?v=3Svs_hl897c", + }, + { + site: "YouTube", + key: "fXmAurh012s", + name: "Official Trailer", + size: 1080, + type: "Trailer", + url: "https://www.youtube.com/watch?v=fXmAurh012s", + }, + ], + homepage: "https://arcane.com", + id: 94605, + inProduction: true, + languages: ["en"], + lastAirDate: "2024-11-09", + name: "Arcane", + networks: [ + { + id: 213, + name: "Netflix", + originCountry: "", + logoPath: "/wwemzKWzjKYJFfCeiB57q3r4Bcm.png", + }, + ], + numberOfEpisodes: 18, + numberOfSeasons: 2, + originCountry: ["US"], + originalLanguage: "en", + originalName: "Arcane", + tagline: "The hunt is on.", + overview: + "Amid the stark discord of twin cities Piltover and Zaun, two sisters fight on rival sides of a war between magic technologies and clashing convictions.", + popularity: 1437.972, + productionCompanies: [ + { + id: 99496, + name: "Fortiche Production", + originCountry: "FR", + logoPath: "/6WTCdsmIH6qR2zFVHlqjpIZhD5A.png", + }, + { + id: 124172, + name: "Riot Games", + originCountry: "US", + logoPath: "/sBlhznEktXKBqC87Bsfwpo1YbYR.png", + }, + ], + productionCountries: [ + { iso_3166_1: "FR", name: "France" }, + { iso_3166_1: "US", name: "United States of America" }, + ], + contentRatings: { + results: [ + { descriptors: [], iso_3166_1: "US", rating: "TV-14" }, + { descriptors: [], iso_3166_1: "AU", rating: "MA 15+" }, + { descriptors: [], iso_3166_1: "RU", rating: "18+" }, + { descriptors: [], iso_3166_1: "DE", rating: "16" }, + { descriptors: [], iso_3166_1: "GB", rating: "15" }, + { descriptors: [], iso_3166_1: "BR", rating: "16" }, + { descriptors: [], iso_3166_1: "NL", rating: "12" }, + { descriptors: [], iso_3166_1: "PT", rating: "16" }, + { descriptors: [], iso_3166_1: "ES", rating: "16" }, + ], + }, + status: "Returning Series", + type: "Scripted", + voteAverage: 8.8, + voteCount: 4141, + backdropPath: "/q8eejQcg1bAqImEV8jh8RtBD4uH.jpg", + posterPath: "/abf8tHznhSvl9BAElD2cQeRr7do.jpg", + externalIds: { + facebookId: "arcaneshow", + imdbId: "tt11126994", + instagramId: "arcaneshow", + tvdbId: 371028, + twitterId: "arcaneshow", + }, + keywords: [ + { id: 2343, name: "magic" }, + { id: 5248, name: "female friendship" }, + { id: 7947, name: "war of independence" }, + { id: 14643, name: "battle" }, + { id: 41645, name: "based on video game" }, + { id: 146946, name: "death in family" }, + { id: 161919, name: "adult animation" }, + { id: 192913, name: "warrior" }, + { id: 193319, name: "broken family" }, + { id: 273967, name: "war" }, + { id: 288793, name: "power" }, + { id: 311315, name: "dramatic" }, + { id: 321464, name: "intense" }, + ], +} as const + +// --- Tests --- + +describe("Filter Matching Tests", () => { + describe("Advanced Filter Conditions Tests", () => { + it("Test require condition with missing genre", () => { + const filters = [ + { + media_type: "movie" as const, + conditions: { + keywords: { include: "epic" }, + genres: { require: ["Action"] }, + }, + apply: "require-test", + }, + ] + const result = findInstances( + movieWebhook as any, + { ...movieGladiator2Data, genres: [{ id: 12, name: "Adventure" }] } as any, + filters as any + ) + strictEqual(result, null) + }) + + it("Test exclude condition with excluded keyword present", () => { + const additionalFilters = [ + { + media_type: "movie" as const, + conditions: { + keywords: { include: "epic", exclude: "horror" }, // fixed to match expectation + genres: { require: "Action" }, + }, + apply: "require-include-test", + }, + ] + + const data = { + ...movieGladiator2Data, + keywords: [...movieGladiator2Data.keywords, { id: 9999, name: "horror" }], + } + + const result = findInstances(movieWebhook as any, data as any, additionalFilters as any) + strictEqual(result, null) + }) + + it("Test include condition with partial match", () => { + const filters = [ + { + media_type: "movie" as const, + conditions: { keywords: { include: "epic" } }, + apply: "include-test", + }, + ] + const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) + strictEqual(result, "include-test") + }) + + it("Test simple string condition", () => { + const simpleFilter = [ + { + media_type: "movie" as const, + conditions: { genres: { require: "Action" } }, // require to make intent explicit + apply: "simple-genre-test", + }, + ] + const result = findInstances(movieWebhook as any, movieGladiator2Data as any, simpleFilter as any) + strictEqual(result, "simple-genre-test") + }) + + it("Test array condition", () => { + const filters = [ + { + media_type: "movie" as const, + conditions: { genres: ["Action", "Adventure"] }, + apply: "array-test", + }, + ] + const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) + strictEqual(result, "array-test") + }) + + it("Test object condition with include", () => { + const includeFilter = [ + { + media_type: "movie" as const, + conditions: { genres: { include: "Action" } }, + apply: "include-test", + }, + ] + const result = findInstances(movieWebhook as any, movieGladiator2Data as any, includeFilter as any) + strictEqual(result, "include-test") + }) + + it("Test multiple exclude conditions", () => { + const filters = [ + { + media_type: "movie" as const, + conditions: { keywords: { exclude: ["horror", "anime", "romance"] } }, + apply: "exclude-test", + }, + ] + const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) + strictEqual(result, "exclude-test") + }) + + it("Test exclude with one matching condition", () => { + const filters = [ + { + media_type: "movie" as const, + conditions: { keywords: { exclude: ["horror", "fantasy"] } }, + apply: "exclude-test", + }, + ] + const data = { + ...movieGladiator2Data, + keywords: [...movieGladiator2Data.keywords, { id: 9999, name: "fantasy" }], + } + const result = findInstances(movieWebhook as any, data as any, filters as any) + strictEqual(result, null) + }) + + it("Test keywords with include, require, and exclude in one condition", () => { + const filters = [ + { + media_type: "movie" as const, + conditions: { + keywords: { include: "gladiator", require: "epic", exclude: "horror" }, + }, + apply: "complex-keyword-test", + }, + ] + const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) + strictEqual(result, "complex-keyword-test") + }) + + it("Test multiple condition types across different fields", () => { + const filters = [ + { + media_type: "movie" as const, + conditions: { + keywords: { include: "epic" }, + genres: { require: "Action" }, + originalLanguage: "en", + }, + apply: "multi-test", + }, + ] + const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) + strictEqual(result, "multi-test") + }) + + it("Test complex condition with all types", () => { + const filters = [ + { + media_type: "movie" as const, + conditions: { + keywords: { include: "epic", exclude: "horror" }, + genres: { require: "Action" }, + originalLanguage: "en", + }, + apply: "complex-test", + }, + ] + const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) + strictEqual(result, "complex-test") + }) + + it("Test complex condition with negative case", () => { + const filters = [ + { + media_type: "movie" as const, + conditions: { + keywords: { include: "epic", exclude: "horror" }, + genres: { require: "Action" }, + originalLanguage: "fr", + }, + apply: "complex-test", + }, + ] + const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) + strictEqual(result, null) + }) + }) + + it("Test movie filter with exclude keyword", () => { + const filters = [ + { + media_type: "movie" as const, + conditions: { originalLanguage: "en" }, + apply: "lang-test", + }, + ] + const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) + strictEqual(result, "lang-test") + }) + + it("Exclude movie with keyword", () => { + const filters = [ + { + media_type: "movie" as const, + conditions: { keywords: { exclude: "epic" } }, + apply: "exclude-test", + }, + ] + const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) + strictEqual(result, null) + }) + + it("Match show with language only", () => { + const filters = [ + { + media_type: "tv" as const, + conditions: { originalLanguage: "en" }, + apply: "lang-test", + }, + ] + const result = findInstances(showWebhook as any, showArcaneData as any, filters as any) + strictEqual(result, "lang-test") + }) + + it("Test keyword exclusion", () => { + const filters = [ + { + media_type: "movie" as const, + conditions: { keywords: { exclude: "epic" }, originalLanguage: "en" }, + apply: "exclude-test", + }, + ] + const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) + strictEqual(result, null) + }) + + it("Test keyword matching", () => { + const filters = [ + { + media_type: "movie" as const, + conditions: { keywords: "power" }, + apply: "keyword-test", + }, + ] + const result = findInstances( + movieWebhook as any, + { + ...movieGladiator2Data, + keywords: [{ id: 1, name: "power" }], + } as any, + filters as any + ) + strictEqual(result, "keyword-test") + }) + + it("Match movie based on age rating", () => { + const filters = [ + { + media_type: "movie" as const, + conditions: { contentRatings: "16" }, + apply: "rating-test", + }, + ] + const result = findInstances( + movieWebhook as any, + { + ...movieGladiator2Data, + contentRatings: { results: [{ rating: "16" }] }, + } as any, + filters as any + ) + strictEqual(result, "rating-test") + }) + + it("Handle non-matching cases gracefully", () => { + const filters = [ + { + media_type: "movie" as const, + conditions: { + keywords: "epic", + genres: ["Adventure", "Drama"], + originalLanguage: "pl", + }, + apply: "non-match-test", + }, + ] + const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) + strictEqual(result, null) + }) + + it("Match a complex filter with mixed types (strings, arrays)", () => { + const filters = [ + { + media_type: "tv" as const, + conditions: { + keywords: ["power", "dramatic"], + genres: ["Sci-Fi & Fantasy", "Animation"], + }, + apply: "complex-mixed-test", + }, + ] + const result = findInstances(showWebhook as any, showArcaneData as any, filters as any) + strictEqual(result, "complex-mixed-test") + }) +}) From 1d2124904eae5747e0023f6e51fd588daa31f6c2 Mon Sep 17 00:00:00 2001 From: varthe Date: Sun, 21 Sep 2025 22:37:27 +0100 Subject: [PATCH 3/5] make matchKeywords recursive --- src/services/filter.ts | 79 ++++++++++++++---------------------------- 1 file changed, 26 insertions(+), 53 deletions(-) diff --git a/src/services/filter.ts b/src/services/filter.ts index 07aeff1..7b8f0a1 100644 --- a/src/services/filter.ts +++ b/src/services/filter.ts @@ -8,69 +8,42 @@ import type { Webhook, MediaData, Filter, Condition, Keyword, ContentRatings } f * - required=false: substring match against any extracted string */ export const matchValue = (filterValue: any, dataValue: any, required = false): boolean => { - const filterValues = new Set(normalizeToArray(filterValue)) + const requiredSet = required ? new Set(normalizeToArray(filterValue)) : null + const anySet = required ? null : new Set(normalizeToArray(filterValue)) - const check = (s: string): boolean => { - if (required) return filterValues.has(s) - for (const f of filterValues) if (s.includes(f)) return true - return false - } + let anyMatched = false - // Object: scan values (including nested objects, arrays of objects, and arrays of primitives) - if (isObject(dataValue)) { - const allValues: string[] = [] - - for (const value of Object.values(dataValue)) { - if (isObject(value)) { - const values = Object.values(value as Record).map((x) => String(x).toLowerCase()) - for (const v of values) if (check(v)) return true - allValues.push(...values) - } else if (isObjectArray(value)) { - for (const item of value as any[]) { - for (const field of Object.values(item)) { - const s = String(field).toLowerCase() - if (check(s)) return true - allValues.push(s) - } - } - } else if (Array.isArray(value)) { - for (const el of value) { - const s = String(el).toLowerCase() - if (check(s)) return true - allValues.push(s) - } - } else { - const s = String(value).toLowerCase() - if (check(s)) return true - allValues.push(s) - } + const visit = (val: unknown): void => { + if (anyMatched && !required) return + + if (isObject(val)) { + for (const v of Object.values(val as Record)) visit(v) + return } - return allValues.some(check) - } + if (Array.isArray(val)) { + for (const el of val) visit(el) + return + } + + const s = String(val).toLowerCase() - // Array of objects - if (isObjectArray(dataValue)) { - for (const item of dataValue as any[]) { - for (const field of Object.values(item)) { - const s = String(field).toLowerCase() - if (check(s)) return true + if (required) { + if (requiredSet!.has(s)) requiredSet!.delete(s) + } else { + // substring "include" semantics + for (const f of anySet!) { + if (s.includes(f)) { + anyMatched = true + break + } } } - return false } - // Array of primitives - if (Array.isArray(dataValue)) { - for (const el of dataValue) { - const s = String(el).toLowerCase() - if (check(s)) return true - } - return false - } + visit(dataValue) - // Primitive - return check(String(dataValue).toLowerCase()) + return required ? requiredSet!.size === 0 : anyMatched } /** From c75a7246925a3016cf96a258af97298024dc42f8 Mon Sep 17 00:00:00 2001 From: varthe Date: Sun, 21 Sep 2025 23:05:35 +0100 Subject: [PATCH 4/5] update bun to 1.2.21 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index f2265ef..ad8f910 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG BUN_VERSION=1.2.14 +ARG BUN_VERSION=1.2.21 FROM oven/bun:${BUN_VERSION}-alpine AS base WORKDIR /app From 0be3d937f7f3b446917be59f8a52e5518dbaa4b0 Mon Sep 17 00:00:00 2001 From: varthe Date: Sun, 21 Sep 2025 23:07:51 +0100 Subject: [PATCH 5/5] Update README --- README.md | 152 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 79 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index e78814c..21dbb08 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,71 @@ -# Redirecter +# Redirecterr ## Docker Compose + ```yaml services: - redirecterr: - image: varthe/redirecterr:latest - container_name: redirecterr - hostname: redirecterr - ports: - - 8481:8481 - volumes: - - /path/to/config.yaml:/app/config.yaml - - /path/to/logs:/logs - environment: - - LOG_LEVEL=info + redirecterr: + image: varthe/redirecterr:latest + container_name: redirecterr + hostname: redirecterr + ports: + - 8481:8481 + volumes: + - /path/to/config.yaml:/app/config.yaml + - /path/to/logs:/logs + environment: + - LOG_LEVEL=info ``` ## Webhook setup + > [!IMPORTANT] > Disable automatic request approval for your users In Overseerr go to **Settings -> Notifications -> Webhook** and configure the following: -- **Enable Agent**: Enabled -- **Webhook URL**: `http://redirecterr:8481/webhook` -- **Notification Types**: Select **Request Pending Approval** -- **JSON Payload**: - ```json - { - "notification_type": "{{notification_type}}", - "media": { - "media_type": "{{media_type}}", - "tmdbId": "{{media_tmdbid}}", - "status": "{{media_status}}", - "status4k": "{{media_status4k}}" - }, - "request": { - "request_id": "{{request_id}}", - "requestedBy_email": "{{requestedBy_email}}", - "requestedBy_username": "{{requestedBy_username}}", - }, - "{{extra}}": [] - } - ``` +- **Enable Agent**: Enabled +- **Webhook URL**: `http://redirecterr:8481/webhook` +- **Notification Types**: Select **Request Pending Approval** +- **JSON Payload**: + ```json + { + "notification_type": "{{notification_type}}", + "media": { + "media_type": "{{media_type}}", + "tmdbId": "{{media_tmdbid}}", + "status": "{{media_status}}", + "status4k": "{{media_status4k}}" + }, + "request": { + "request_id": "{{request_id}}", + "requestedBy_email": "{{requestedBy_email}}", + "requestedBy_username": "{{requestedBy_username}}" + }, + "{{extra}}": [] + } + ``` ## Config + Create a `config.yaml` file with the following sections: ### Overseerr settings + ```yaml overseerr_url: "" overseerr_api_token: "" -approve_on_no_match: true # Auto-approve if no filters match +approve_on_no_match: true # Auto-approve if no filters match ``` ### Instances + Define your Radarr/Sonarr instances ```yaml instances: - radarr: - server_id: 0 # Match the order in Overseerr > Settings > Services (example below) + radarr: + server_id: 0 # Match the order in Overseerr > Settings > Services (example below) root_folder: /mnt/movies # quality_profile_id: 1 # Optional # approve: false # Optional (default is true) @@ -72,8 +77,8 @@ instances: ``` http:///api/v3/qualityProfile?apiKey= ``` -- `approve`: Set to false to disable auto-approval. +- `approve`: Set to false to disable auto-approval. ### Filters @@ -81,33 +86,34 @@ Filters route requests based on conditions. ```yaml filters: - - media_type: movie - # is_4k: true # Optional - conditions: - keywords: - include: ["anime", "animation"] - contentRatings: - exclude: [12, 16] - requestedBy_username: user - max_seasons: 2 - apply: radarr_anime + - media_type: movie + # is_4k: true # Optional + conditions: + keywords: + include: ["anime", "animation"] + contentRatings: + exclude: [12, 16] + requestedBy_username: user + max_seasons: 2 + apply: radarr_anime ``` #### Fields -- `media_type`: `movie` or `tv` -- `is_4k` (Optional): Set to `true` to only match 4K requests. Set to `false` to only match non-4k requests. Leave empty to match both. -- `conditions`: - - `field`: - - `require`: All values must match - - `exclude`: None of the values must match - - `include`: At least one value matches -- `apply`: One or more instance names +- `media_type`: `movie` or `tv` +- `is_4k` (Optional): Set to `true` to only match 4K requests. Set to `false` to only match non-4k requests. Leave empty to match both. +- `conditions`: + - `field`: + - `require`: All values must match + - `exclude`: None of the values must match + - `include`: At least one value matches +- `apply`: One or more instance names > [!TIP] > For a list of possible condition fields see [fields.md](https://github.com/varthe/Redirecterr/blob/main/fields.md) ### Sample config + ```yaml overseerr_url: "" overseerr_api_token: "" @@ -115,24 +121,24 @@ overseerr_api_token: "" approve_on_no_match: true instances: - sonarr: - server_id: 0 - root_folder: "/mnt/plex/Shows" - sonarr_4k: - server_id: 1 - root_folder: "/mnt/plex/Shows - 4K" - sonarr_anime: - server_id: 2 - root_folder: "/mnt/plex/Anime" + sonarr: + server_id: 0 + root_folder: "/mnt/plex/Shows" + sonarr_4k: + server_id: 1 + root_folder: "/mnt/plex/Shows - 4K" + sonarr_anime: + server_id: 2 + root_folder: "/mnt/plex/Anime" filters: - # Send anime to sonarr_anime - - media_type: tv - conditions: - keywords: anime - apply: sonarr_anime - - # Send everything else to sonarr and sonarr_4k instances - - media_type: tv - apply: ["sonarr", "sonarr_4k"] + # Send anime to sonarr_anime + - media_type: tv + conditions: + keywords: anime + apply: sonarr_anime + + # Send everything else to sonarr and sonarr_4k instances + - media_type: tv + apply: ["sonarr", "sonarr_4k"] ```