├── src ├── types │ ├── index.ts │ ├── config.ts │ └── webhook.ts ├── utils │ ├── logger.ts │ └── helpers.ts ├── main.ts ├── services │ ├── instance.ts │ ├── webhook.ts │ └── filter.ts ├── api │ └── overseerr.ts └── config │ └── index.ts ├── .gitignore ├── Dockerfile ├── .github └── workflows │ ├── pr-tests.yaml │ ├── publish-beta-image.yaml │ └── publish-image.yaml ├── package.json ├── fields.md ├── .dockerignore ├── tsconfig.json ├── README.md ├── bun.lock └── filters.test.ts /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './webhook' 2 | export * from './config' 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs/ 3 | test/ 4 | 5 | # Dependency directories 6 | node_modules/ 7 | 8 | # Secrets and credentials 9 | secrets/ 10 | 11 | # IDE configurations 12 | .vscode/ 13 | .idea/ 14 | *.iml 15 | 16 | # Configuration files 17 | eslint.config.mjs 18 | .prettierrc 19 | .prettierignore 20 | config.yaml 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BUN_VERSION=1.2.21 2 | 3 | FROM oven/bun:${BUN_VERSION}-alpine AS base 4 | WORKDIR /app 5 | 6 | COPY package.json bun.lockb* tsconfig.json ./ 7 | 8 | RUN bun install --production --frozen-lockfile 9 | 10 | COPY . . 11 | 12 | EXPOSE 8481 13 | 14 | VOLUME /logs 15 | VOLUME /config 16 | 17 | CMD ["bun", "run", "src/main.ts", "/logs", "/config/config.yaml"] 18 | -------------------------------------------------------------------------------- /.github/workflows/pr-tests.yaml: -------------------------------------------------------------------------------- 1 | name: Run tests on pull request 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v3 13 | 14 | - name: Install Bun 15 | uses: oven-sh/setup-bun@v1 16 | with: 17 | bun-version: latest 18 | 19 | - name: Install dependencies with Bun 20 | run: bun install 21 | 22 | - name: Run tests with Bun 23 | run: bun test 24 | -------------------------------------------------------------------------------- /.github/workflows/publish-beta-image.yaml: -------------------------------------------------------------------------------- 1 | name: Publish beta image to Docker Hub 2 | 3 | on: 4 | release: 5 | types: [prereleased] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | publish_beta_image: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: checkout 13 | uses: actions/checkout@v3 14 | 15 | - name: Set up Docker Buildx 16 | uses: docker/setup-buildx-action@v2 17 | 18 | - name: Log in to Docker Hub 19 | run: | 20 | echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u varthe --password-stdin 21 | 22 | - name: Build and push multi-arch beta image 23 | run: | 24 | docker buildx create --use 25 | docker buildx build --platform linux/amd64,linux/arm64 -t varthe/redirecterr:beta --push . -------------------------------------------------------------------------------- /.github/workflows/publish-image.yaml: -------------------------------------------------------------------------------- 1 | name: Publish image to Docker Hub 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | publish_image: 10 | if: github.event.release.prerelease == false 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: checkout 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up Docker Buildx 17 | uses: docker/setup-buildx-action@v2 18 | 19 | - name: Log in to Docker Hub 20 | run: | 21 | echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u varthe --password-stdin 22 | 23 | - name: Build and push multi-arch image 24 | run: | 25 | docker buildx create --use 26 | docker buildx build --platform linux/amd64,linux/arm64 -t varthe/redirecterr:latest --push . 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redirecterr", 3 | "version": "1.0.0", 4 | "description": "Redirects Overseerr requests to different instances based on filters", 5 | "module": "src/main.ts", 6 | "type": "module", 7 | "private": true, 8 | "scripts": { 9 | "start": "bun src/main.ts", 10 | "dev": "bun --watch src/main.ts", 11 | "test": "bun test" 12 | }, 13 | "author": "Patryk Kaczmarek", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@types/bun": "latest", 17 | "@types/js-yaml": "^4.0.9", 18 | "@types/node": "^22.15.19" 19 | }, 20 | "peerDependencies": { 21 | "typescript": "^5" 22 | }, 23 | "dependencies": { 24 | "ajv": "^8.17.1", 25 | "js-yaml": "^4.1.0", 26 | "winston": "^3.11.0", 27 | "winston-daily-rotate-file": "^4.7.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import winston, { Logger } from "winston" 3 | import DailyRotateFile from "winston-daily-rotate-file" 4 | 5 | const filePath: string = process.argv[2] || "../logs" 6 | const logLevel: string = process.env.LOG_LEVEL || "info" 7 | 8 | const logger: Logger = winston.createLogger({ 9 | level: logLevel, 10 | format: winston.format.combine( 11 | winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), 12 | winston.format.printf(({ timestamp, level, message }) => { 13 | return `${timestamp} [${level.toUpperCase()}]: ${message}` 14 | }) 15 | ), 16 | transports: [ 17 | new winston.transports.Console(), 18 | new DailyRotateFile({ 19 | filename: path.join(filePath, "redirecterr-%DATE%.log"), 20 | datePattern: "YYYY-MM-DD", 21 | maxSize: "500k", 22 | maxFiles: "7d", 23 | }), 24 | ], 25 | }) 26 | 27 | export default logger 28 | -------------------------------------------------------------------------------- /src/types/config.ts: -------------------------------------------------------------------------------- 1 | export type Condition = string | string[] | ConditionValueObject 2 | 3 | export interface ConditionValueObject { 4 | include?: string | string[] 5 | exclude?: string | string[] 6 | require?: string | string[] 7 | } 8 | 9 | interface FilterCondition { 10 | [key: string]: Condition // For dynamic condition keys like "tag", "language" etc. 11 | } 12 | 13 | export interface Filter { 14 | media_type: "movie" | "tv" 15 | is_4k?: boolean 16 | conditions?: FilterCondition 17 | apply: string | string[] 18 | } 19 | 20 | interface InstanceConfig { 21 | server_id: number 22 | root_folder: string 23 | quality_profile_id?: number 24 | approve?: boolean 25 | } 26 | 27 | export interface Config { 28 | overseerr_url: string 29 | overseerr_api_token: string 30 | approve_on_no_match?: boolean 31 | instances: { 32 | [key: string]: InstanceConfig // For dynamic instance names 33 | } 34 | filters: Filter[] 35 | } 36 | -------------------------------------------------------------------------------- /fields.md: -------------------------------------------------------------------------------- 1 | # Redirecterr fields 2 | 3 | This is a comprehensive list of possible fields that may appear in incoming request data from Overseerr/Jellyseerr and can be used in filters. 4 | 5 | Example values for each field can be found in [filters.test.ts](https://github.com/varthe/Redirecterr/blob/main/filters.test.ts) 6 | 7 | - `requestedBy_email` 8 | - `requestedBy_username` 9 | - `max_seasons` 10 | 11 |
12 | 13 | - `id` 14 | - `title` / `name` 15 | - `originalTitle` / `originalName` 16 | - `tagline` 17 | - `overview` 18 | - `genres` 19 | - `keywords` 20 | - `releaseDate` / `firstAirDate` 21 | - `runtime` / `episodeRunTime` 22 | - `status` 23 | - `voteAverage` 24 | - `voteCount` 25 | - `popularity` 26 | - `adult` 27 | - `video` 28 | - `budget` 29 | - `revenue` 30 | - `homepage` 31 | - `originalLanguage` 32 | - `spokenLanguages` 33 | - `productionCompanies` 34 | - `productionCountries` 35 | - `networks` 36 | - `inProduction` 37 | - `numberOfSeasons` / `numberOfEpisodes` 38 | - `contentRatings` 39 | -------------------------------------------------------------------------------- /src/types/webhook.ts: -------------------------------------------------------------------------------- 1 | export interface Webhook { 2 | notification_type: string 3 | media: Media 4 | request: Request 5 | extra?: Array 6 | } 7 | 8 | export interface Media { 9 | media_type: string 10 | tmdbId: string 11 | status: string 12 | status4k: string 13 | } 14 | 15 | export interface Request { 16 | request_id: string 17 | requestedBy_username: string 18 | requestedBy_email: string 19 | } 20 | 21 | export interface MediaData { 22 | originalTitle?: string 23 | originalName?: string 24 | keywords: Array 25 | contentRatings: ContentRatings 26 | [key: string]: any 27 | } 28 | 29 | export interface Keyword { 30 | name: string 31 | } 32 | 33 | export interface ContentRating { 34 | iso_3116_1: string 35 | rating: string 36 | } 37 | 38 | export interface ContentRatings { 39 | results: ContentRating[] 40 | } 41 | 42 | export interface PostData { 43 | mediaType: string 44 | seasons?: number[] 45 | } 46 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | vendor/ 4 | 5 | # Version control 6 | .git/ 7 | .gitignore 8 | .github/ 9 | 10 | # Logs 11 | logs/ 12 | *.log 13 | 14 | # Runtime data 15 | pids/ 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Build artifacts 21 | bin/ 22 | dist/ 23 | tmp/ 24 | *.exe 25 | *.test 26 | *.out 27 | 28 | # Debugging files 29 | *.prof 30 | *.trace 31 | # 32 | # Secrets and credentials 33 | .env 34 | .env.*.local 35 | secrets/ 36 | 37 | # Config files 38 | *.yaml 39 | *.yml 40 | *.toml 41 | *.ini 42 | *.conf 43 | 44 | # Documentation & misc files 45 | *.md 46 | *.txt 47 | LICENSE 48 | README* 49 | 50 | # IDE configurations 51 | .vscode/ 52 | .idea/ 53 | *.iml 54 | 55 | # OS-specific files 56 | .DS_Store 57 | Thumbs.db 58 | 59 | # Temporary files 60 | *.sqlite 61 | *.sqlite-journal 62 | .cursorrules 63 | 64 | # Editor temp files 65 | *~ 66 | *.swp 67 | *.swo 68 | 69 | # Tests 70 | filters.test.js 71 | test/ 72 | 73 | 74 | # Don't ignore these important files 75 | !package.json 76 | !package-lock.json 77 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Environment setup & latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | 23 | // Some stricter flags (disabled by default) 24 | "noUnusedLocals": false, 25 | "noUnusedParameters": false, 26 | "noPropertyAccessFromIndexSignature": false, 27 | 28 | // Allow importing JS files 29 | "esModuleInterop": true, 30 | "resolveJsonModule": true 31 | }, 32 | "include": ["src/**/*"], 33 | "exclude": ["node_modules", "dist"] 34 | } 35 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import logger from "./utils/logger" 2 | import { isWebhook } from "./utils/helpers" 3 | import { handleWebhook, createResponse } from "./services/webhook" 4 | 5 | const PORT = process.env.PORT || 8481 6 | logger.info(`Redirecterr listening on port ${PORT}`) 7 | 8 | Bun.serve({ 9 | port: Number(PORT), 10 | async fetch(req: Request): Promise { 11 | const url = new URL(req.url) 12 | 13 | if (req.method !== "POST" || url.pathname !== "/webhook") { 14 | return createResponse("error", "Invalid URL. Use the /webhook endpoint", 400) 15 | } 16 | 17 | try { 18 | const webhook = await req.json() 19 | 20 | if (!isWebhook(webhook)) { 21 | return createResponse( 22 | "error", 23 | "Invalid webhook structure. Ensure 'media' and 'request' objects are present", 24 | 400 25 | ) 26 | } 27 | 28 | // Process the webhook 29 | return handleWebhook(webhook) 30 | } catch (error) { 31 | return createResponse("error", `Error processing webhook: ${error}`, 500) 32 | } 33 | }, 34 | }) 35 | -------------------------------------------------------------------------------- /src/services/instance.ts: -------------------------------------------------------------------------------- 1 | import logger from "../utils/logger" 2 | import { config } from "../config" 3 | import { approveRequest, applyConfig } from "../api/overseerr" 4 | import { buildDebugLogMessage } from "../utils/helpers" 5 | import type { PostData } from "../types" 6 | 7 | /** 8 | * Send request to configured instances 9 | */ 10 | export const sendToInstances = async (instances: string | string[], requestId: string, data: PostData): Promise => { 11 | const instancesArray = Array.isArray(instances) ? instances : [instances] 12 | 13 | for (const item of instancesArray) { 14 | try { 15 | let postData = { ...data } as Record 16 | const instance = config.instances[item] 17 | 18 | if (!instance) { 19 | logger.warn(`Instance "${item}" not found in config`) 20 | continue 21 | } 22 | 23 | // Add instance-specific configuration 24 | postData.rootFolder = instance.root_folder 25 | postData.serverId = instance.server_id 26 | if (instance.quality_profile_id) postData.profileId = instance.quality_profile_id 27 | 28 | if (logger.isDebugEnabled()) { 29 | logger.debug(buildDebugLogMessage("Sending configuration to instance:", { instance: item, postData })) 30 | } 31 | 32 | // Apply configuration to the request 33 | await applyConfig(requestId, postData) 34 | logger.info(`Configuration applied for request ID ${requestId} on instance "${item}"`) 35 | 36 | // Approve the request if configured to do so 37 | if (instance.approve ?? true) { 38 | await approveRequest(requestId) 39 | logger.info(`Request ID ${requestId} approved for instance "${item}"`) 40 | } 41 | } catch (error) { 42 | logger.warn(`Failed to process request ID ${requestId} for instance "${item}": ${error}`) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/api/overseerr.ts: -------------------------------------------------------------------------------- 1 | import { config } from "../config" 2 | import logger from "../utils/logger" 3 | 4 | // Create headers for Overseerr API requests 5 | const headers = { 6 | "X-Api-Key": config.overseerr_api_token, 7 | accept: "application/json", 8 | "Content-Type": "application/json", 9 | } 10 | 11 | /** 12 | * Fetch data from Overseerr API 13 | */ 14 | export const fetchFromOverseerr = async (endpoint: string): Promise => { 15 | const url = new URL(endpoint, config.overseerr_url) 16 | const response = await fetch(url, { headers: headers }) 17 | 18 | if (!response.ok || response.status !== 200) { 19 | throw new Error(`could not retrieve data from Overseerr: ${response.status} ${response.statusText}`) 20 | } 21 | 22 | const data = await response.json() 23 | return data 24 | } 25 | 26 | /** 27 | * Approve a request in Overseerr 28 | */ 29 | export const approveRequest = async (requestId: string): Promise => { 30 | try { 31 | const url = new URL(`/api/v1/request/${requestId}/approve`, config.overseerr_url) 32 | const response = await fetch(url, { method: "POST", headers: headers }) 33 | 34 | if (!response.ok) { 35 | throw new Error(`${response.status} ${response.statusText}`) 36 | } 37 | 38 | logger.info(`Request ID ${requestId} approved successfully`) 39 | } catch (error) { 40 | logger.error(`Error approving request: ${error}`) 41 | } 42 | } 43 | 44 | /** 45 | * Apply configuration to a request in Overseerr 46 | */ 47 | export const applyConfig = async (requestId: string, postData: Record): Promise => { 48 | try { 49 | const url = new URL(`/api/v1/request/${requestId}`, config.overseerr_url) 50 | const response = await fetch(url, { 51 | method: "PUT", 52 | headers: headers, 53 | body: JSON.stringify(postData), 54 | }) 55 | 56 | if (!response.ok) { 57 | throw new Error(`${response.status} ${response.statusText}`) 58 | } 59 | 60 | logger.info(`Configuration applied to request ID ${requestId}`) 61 | } catch (error) { 62 | logger.error(`Error applying configuration: ${error}`) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/utils/helpers.ts: -------------------------------------------------------------------------------- 1 | import type { Webhook, PostData } from "../types" 2 | 3 | /** 4 | * Checks if an object is a valid webhook 5 | */ 6 | export const isWebhook = (obj: any): obj is Webhook => 7 | typeof obj === "object" && "media" in obj && "request" in obj 8 | 9 | /** 10 | * Checks if a value is an object 11 | */ 12 | export const isObject = (value: any): boolean => 13 | typeof value === "object" && value !== null 14 | 15 | /** 16 | * Checks if a value is an array of objects 17 | */ 18 | export const isObjectArray = (value: any): boolean => 19 | Array.isArray(value) && value.some((item: any) => isObject(item)) 20 | 21 | /** 22 | * Extracts post data from a webhook 23 | */ 24 | export const getPostData = (requestData: Webhook): PostData => { 25 | const { media, extra } = requestData 26 | const postData: PostData = { mediaType: media.media_type } 27 | 28 | if (media.media_type !== "tv" || !extra || extra.length === 0) { 29 | return postData 30 | } 31 | 32 | const seasons = extra 33 | .find((item: any) => item.name === "Requested Seasons") 34 | ?.value?.split(",") 35 | .map(Number) 36 | .filter(Number.isInteger) 37 | 38 | if (seasons?.length > 0) { 39 | postData["seasons"] = seasons 40 | } 41 | 42 | return postData 43 | } 44 | 45 | /** 46 | * Normalizes a value to an array of lowercase strings 47 | */ 48 | export const normalizeToArray = (value: any): string[] => { 49 | const values = Array.isArray(value) ? value : [value] 50 | return values.map((x) => String(x).toLowerCase()) 51 | } 52 | 53 | /** 54 | * Formats a debug log entry 55 | */ 56 | export const formatDebugLogEntry = (entry: any): string => { 57 | if (Array.isArray(entry)) { 58 | return entry 59 | .map((item) => 60 | isObject(item) && "name" in item ? item.name : isObject(item) ? JSON.stringify(item) : item 61 | ) 62 | .join(", ") 63 | } 64 | if (isObject(entry)) { 65 | if ("name" in entry) { 66 | return entry.name as string 67 | } 68 | return Object.entries(entry) 69 | .map(([key, value]) => `${key}: ${Array.isArray(value) ? `[${value.join(", ")}]` : value}`) 70 | .join(", ") 71 | } 72 | return String(entry) 73 | } 74 | 75 | /** 76 | * Builds a debug log message with formatted details 77 | */ 78 | export const buildDebugLogMessage = (message: string, details: Record = {}): string => { 79 | const formattedDetails = Object.entries(details) 80 | .map(([key, value]) => `${key}: ${formatDebugLogEntry(value)}`) 81 | .join("\n") 82 | 83 | return `${message}\n${formattedDetails}` 84 | } 85 | -------------------------------------------------------------------------------- /src/services/webhook.ts: -------------------------------------------------------------------------------- 1 | import logger from "../utils/logger" 2 | import { config } from "../config" 3 | import { approveRequest, fetchFromOverseerr } from "../api/overseerr" 4 | import { getPostData } from "../utils/helpers" 5 | import { findInstances } from "./filter" 6 | import { sendToInstances } from "./instance" 7 | import { buildDebugLogMessage } from "../utils/helpers" 8 | import type { Webhook } from "../types" 9 | 10 | /** 11 | * Create a standardized response 12 | */ 13 | export const createResponse = (status: string, message: string, statusCode: number): Response => { 14 | if (status === "error") logger.error(message) 15 | else logger.info(message) 16 | 17 | return new Response(JSON.stringify({ status: status, message: message }), { 18 | headers: { "Content-Type": "application/json" }, 19 | status: statusCode, 20 | }) 21 | } 22 | 23 | /** 24 | * Handle webhook requests 25 | */ 26 | export const handleWebhook = async (webhook: Webhook): Promise => { 27 | if (logger.isDebugEnabled()) { 28 | logger.debug(`Received webhook event:\n${JSON.stringify(webhook, null, 2)}`) 29 | } 30 | 31 | // Handle test notifications 32 | if (webhook.notification_type === "TEST_NOTIFICATION") { 33 | return createResponse("success", "Test notification received", 200) 34 | } 35 | 36 | const { media, request } = webhook 37 | 38 | // Auto-approve music requests 39 | if (media.media_type === "music") { 40 | await approveRequest(request.request_id) 41 | return createResponse("success", "Music request approved", 200) 42 | } 43 | 44 | try { 45 | // Fetch media data from Overseerr 46 | const data = await fetchFromOverseerr(`/api/v1/${media.media_type}/${media.tmdbId}`) 47 | logger.info( 48 | `Received request ID ${request.request_id} for ${media.media_type} "${data?.originalTitle || data?.originalName}"` 49 | ) 50 | 51 | // Log detailed request information in debug mode 52 | if (logger.isDebugEnabled()) { 53 | const cleanMetadata = Object.fromEntries( 54 | Object.entries(data).filter( 55 | ([key]) => !["credits", "relatedVideos", "networks", "watchProviders"].includes(key) 56 | ) 57 | ) 58 | logger.debug( 59 | buildDebugLogMessage("Request details:", { 60 | webhook: JSON.stringify(webhook, null, 2), 61 | metadata: JSON.stringify(cleanMetadata, null, 2), 62 | }) 63 | ) 64 | } 65 | 66 | // Find matching instances based on filters 67 | const instances = findInstances(webhook, data, config.filters) 68 | const postData = getPostData(webhook) 69 | 70 | // Process request based on filter matches 71 | if (instances) { 72 | await sendToInstances(instances, request.request_id, postData) 73 | return createResponse("success", `Request processed and sent to instances`, 200) 74 | } else if (config.approve_on_no_match) { 75 | logger.info(`Approving unmatched request ID ${request.request_id}`) 76 | await approveRequest(request.request_id) 77 | return createResponse("success", "Request approved (no matching filter)", 200) 78 | } 79 | 80 | return createResponse("success", "Request processed (no action taken)", 200) 81 | } catch (error) { 82 | return createResponse("error", `Error processing webhook: ${error}`, 500) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redirecterr 2 | 3 | ## Docker Compose 4 | 5 | ```yaml 6 | services: 7 | redirecterr: 8 | image: varthe/redirecterr:latest 9 | container_name: redirecterr 10 | hostname: redirecterr 11 | ports: 12 | - 8481:8481 13 | volumes: 14 | - /path/to/config.yaml:/config/config.yaml 15 | - /path/to/logs:/logs 16 | environment: 17 | - LOG_LEVEL=info 18 | ``` 19 | 20 | ## Webhook setup 21 | 22 | > [!IMPORTANT] 23 | > Disable automatic request approval for your users 24 | 25 | In Overseerr go to **Settings -> Notifications -> Webhook** and configure the following: 26 | 27 | - **Enable Agent**: Enabled 28 | - **Webhook URL**: `http://redirecterr:8481/webhook` 29 | - **Notification Types**: Select **Request Pending Approval** 30 | - **JSON Payload**: 31 | ```json 32 | { 33 | "notification_type": "{{notification_type}}", 34 | "media": { 35 | "media_type": "{{media_type}}", 36 | "tmdbId": "{{media_tmdbid}}", 37 | "status": "{{media_status}}", 38 | "status4k": "{{media_status4k}}" 39 | }, 40 | "request": { 41 | "request_id": "{{request_id}}", 42 | "requestedBy_email": "{{requestedBy_email}}", 43 | "requestedBy_username": "{{requestedBy_username}}" 44 | }, 45 | "{{extra}}": [] 46 | } 47 | ``` 48 | 49 | ## Config 50 | 51 | Create a `config.yaml` file with the following sections: 52 | 53 | ### Overseerr settings 54 | 55 | ```yaml 56 | overseerr_url: "" 57 | overseerr_api_token: "" 58 | approve_on_no_match: true # Auto-approve if no filters match 59 | ``` 60 | 61 | ### Instances 62 | 63 | Define your Radarr/Sonarr instances 64 | 65 | ```yaml 66 | instances: 67 | radarr: 68 | server_id: 0 # Match the order in Overseerr > Settings > Services (example below) 69 | root_folder: /mnt/movies 70 | # quality_profile_id: 1 # Optional 71 | # approve: false # Optional (default is true) 72 | ``` 73 | 74 | - `server_id`: Starts at 0, increases left to right in Overseerr UI. [Visual example](https://github.com/user-attachments/assets/a7a60d91-0f24-42a9-bbe1-ea4f1c945e6a) 75 | - `quality_profile_id` (Optional): Override Overseerr default. Get IDs from: 76 | 77 | ``` 78 | http:///api/v3/qualityProfile?apiKey= 79 | ``` 80 | 81 | - `approve`: Set to false to disable auto-approval. 82 | 83 | ### Filters 84 | 85 | Filters route requests based on conditions. 86 | 87 | ```yaml 88 | filters: 89 | - media_type: movie 90 | # is_4k: true # Optional 91 | conditions: 92 | keywords: 93 | include: ["anime", "animation"] 94 | contentRatings: 95 | exclude: [12, 16] 96 | requestedBy_username: user 97 | max_seasons: 2 98 | apply: radarr_anime 99 | ``` 100 | 101 | #### Fields 102 | 103 | - `media_type`: `movie` or `tv` 104 | - `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. 105 | - `conditions`: 106 | - `field`: 107 | - `require`: All values must match 108 | - `exclude`: None of the values must match 109 | - `include`: At least one value matches 110 | - `apply`: One or more instance names 111 | 112 | > [!TIP] 113 | > For a list of possible condition fields see [fields.md](https://github.com/varthe/Redirecterr/blob/main/fields.md) 114 | 115 | ### Sample config 116 | 117 | ```yaml 118 | overseerr_url: "" 119 | overseerr_api_token: "" 120 | 121 | approve_on_no_match: true 122 | 123 | instances: 124 | sonarr: 125 | server_id: 0 126 | root_folder: "/mnt/plex/Shows" 127 | sonarr_4k: 128 | server_id: 1 129 | root_folder: "/mnt/plex/Shows - 4K" 130 | sonarr_anime: 131 | server_id: 2 132 | root_folder: "/mnt/plex/Anime" 133 | 134 | filters: 135 | # Send anime to sonarr_anime 136 | - media_type: tv 137 | conditions: 138 | keywords: anime 139 | apply: sonarr_anime 140 | 141 | # Send everything else to sonarr and sonarr_4k instances 142 | - media_type: tv 143 | apply: ["sonarr", "sonarr_4k"] 144 | ``` 145 | -------------------------------------------------------------------------------- /src/config/index.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs" 2 | import yaml from "js-yaml" 3 | import Ajv, { type ErrorObject, type Schema } from "ajv" 4 | import logger from "../utils/logger" 5 | import type { Config } from "../types" 6 | 7 | const ajv = new Ajv({ allErrors: true }) 8 | 9 | const yamlFilePath = process.argv[3] || "./config.yaml" 10 | 11 | const schema: Schema = { 12 | $schema: "http://json-schema.org/draft-07/schema#", 13 | type: "object", 14 | properties: { 15 | overseerr_url: { 16 | type: "string", 17 | minLength: 1, 18 | }, 19 | overseerr_api_token: { 20 | type: "string", 21 | minLength: 1, 22 | }, 23 | approve_on_no_match: { 24 | type: "boolean", 25 | }, 26 | instances: { 27 | type: "object", 28 | patternProperties: { 29 | ".*": { 30 | type: "object", 31 | properties: { 32 | server_id: { 33 | type: "number", 34 | }, 35 | root_folder: { 36 | type: "string", 37 | minLength: 1, 38 | }, 39 | quality_profile_id: { 40 | type: "number", 41 | }, 42 | approve: { 43 | type: "boolean", 44 | }, 45 | }, 46 | required: ["server_id", "root_folder"], 47 | }, 48 | }, 49 | additionalProperties: false, 50 | }, 51 | filters: { 52 | type: "array", 53 | items: { 54 | type: "object", 55 | properties: { 56 | media_type: { 57 | type: "string", 58 | enum: ["movie", "tv"], 59 | }, 60 | is_4k: { 61 | type: "boolean", 62 | }, 63 | conditions: { 64 | type: "object", 65 | additionalProperties: { 66 | anyOf: [ 67 | { type: "string" }, 68 | { type: "number" }, 69 | { 70 | type: "array", 71 | items: { type: "string" }, 72 | minItems: 1, 73 | }, 74 | { 75 | type: "object", 76 | properties: { 77 | exclude: { 78 | anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], 79 | }, 80 | }, 81 | required: ["exclude"], 82 | additionalProperties: false, 83 | }, 84 | { 85 | type: "object", 86 | properties: { 87 | require: { 88 | anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], 89 | }, 90 | }, 91 | required: ["require"], 92 | additionalProperties: false, 93 | }, 94 | { 95 | type: "object", 96 | properties: { 97 | include: { 98 | anyOf: [{ type: "string" }, { type: "array", items: { type: "string" } }], 99 | }, 100 | }, 101 | required: ["include"], 102 | additionalProperties: false, 103 | }, 104 | ], 105 | }, 106 | }, 107 | apply: { 108 | anyOf: [ 109 | { type: "string" }, 110 | { 111 | type: "array", 112 | items: { type: "string" }, 113 | minItems: 1, 114 | }, 115 | ], 116 | }, 117 | }, 118 | required: ["media_type", "apply"], 119 | }, 120 | }, 121 | }, 122 | required: ["overseerr_url", "overseerr_api_token", "instances", "filters"], 123 | } 124 | 125 | /** 126 | * Format validation errors into a readable string 127 | */ 128 | const formatErrors = (errors: ErrorObject[] | null | undefined): string => { 129 | if (!errors) return "Unknown validation error" 130 | 131 | return errors 132 | .map((error) => { 133 | const path = error.instancePath || "config" 134 | return `Error at "${path}": ${error.message || "Validation issue"}` 135 | }) 136 | .join("\n") 137 | } 138 | 139 | const validate = ajv.compile(schema) 140 | 141 | /** 142 | * Load and validate the configuration file 143 | */ 144 | const loadConfig = async (): Promise => { 145 | try { 146 | if (!fs.existsSync(yamlFilePath)) { 147 | logger.error(`Configuration file not found at: ${yamlFilePath}`) 148 | process.exit(1) 149 | } 150 | 151 | const fileContents = fs.readFileSync(yamlFilePath, "utf8") 152 | const config = yaml.load(fileContents) 153 | 154 | if (!validate(config)) { 155 | throw new Error(`\n${formatErrors(validate.errors)}`) 156 | } 157 | 158 | if (logger.isDebugEnabled()) { 159 | logger.debug("Debug mode enabled") 160 | 161 | const replacer = (key: string, value: any) => { 162 | if (key === "overseerr_api_token") return "REDACTED" 163 | return value 164 | } 165 | 166 | logger.debug(`Loaded config:\n${JSON.stringify(config, replacer, 2)}`) 167 | } 168 | 169 | return config 170 | } catch (error: any) { 171 | let errorMessage = "An unknown error occurred" 172 | if (error instanceof Error) errorMessage = error.message 173 | else if (typeof error === "string") errorMessage = error 174 | 175 | logger.error(`Error loading config: ${errorMessage}`) 176 | process.exit(1) 177 | } 178 | } 179 | 180 | export const config = await loadConfig() 181 | -------------------------------------------------------------------------------- /src/services/filter.ts: -------------------------------------------------------------------------------- 1 | import logger from "../utils/logger" 2 | import { normalizeToArray, isObject, isObjectArray, buildDebugLogMessage } from "../utils/helpers" 3 | import type { Webhook, MediaData, Filter, Condition, Keyword, ContentRatings } from "../types" 4 | 5 | /** 6 | * Matches filter values against arbitrary data structures. 7 | * - required=true: exact match against any extracted string 8 | * - required=false: substring match against any extracted string 9 | */ 10 | export const matchValue = (filterValue: any, dataValue: any, required = false): boolean => { 11 | const requiredSet = required ? new Set(normalizeToArray(filterValue)) : null 12 | const anySet = required ? null : new Set(normalizeToArray(filterValue)) 13 | 14 | let anyMatched = false 15 | 16 | const visit = (val: unknown): void => { 17 | if (anyMatched && !required) return 18 | 19 | if (isObject(val)) { 20 | for (const v of Object.values(val as Record)) visit(v) 21 | return 22 | } 23 | 24 | if (Array.isArray(val)) { 25 | for (const el of val) visit(el) 26 | return 27 | } 28 | 29 | const s = String(val).toLowerCase() 30 | 31 | if (required) { 32 | if (requiredSet!.has(s)) requiredSet!.delete(s) 33 | } else { 34 | // substring "include" semantics 35 | for (const f of anySet!) { 36 | if (s.includes(f)) { 37 | anyMatched = true 38 | break 39 | } 40 | } 41 | } 42 | } 43 | 44 | visit(dataValue) 45 | 46 | return required ? requiredSet!.size === 0 : anyMatched 47 | } 48 | 49 | /** 50 | * Specialized keyword matcher: handles require/include/exclude directly on keyword names. 51 | */ 52 | export const matchKeywords = (keywords: Array, filterCondition: Condition): boolean => { 53 | const names = keywords.map((k) => k.name.toLowerCase()) 54 | 55 | if (typeof filterCondition === "object" && filterCondition !== null) { 56 | if ("require" in filterCondition && filterCondition.require) { 57 | const req = new Set(normalizeToArray(filterCondition.require)) 58 | if (!names.some((n) => req.has(n))) return false 59 | } 60 | 61 | if ("include" in filterCondition && filterCondition.include) { 62 | const inc = normalizeToArray(filterCondition.include) 63 | if (!names.some((n) => inc.some((v) => n.includes(v)))) return false 64 | } 65 | 66 | if ("exclude" in filterCondition && filterCondition.exclude) { 67 | const exc = normalizeToArray(filterCondition.exclude) 68 | if (names.some((n) => exc.some((v) => n.includes(v)))) return false 69 | } 70 | 71 | return true 72 | } 73 | 74 | const vals = normalizeToArray(filterCondition) 75 | return names.some((n) => vals.some((v) => n.includes(v))) 76 | } 77 | 78 | /** 79 | * Specialized content rating matcher: handles require/include/exclude on rating strings. 80 | */ 81 | export const matchContentRatings = (contentRatings: ContentRatings, filterCondition: Condition): boolean => { 82 | if (!contentRatings || !contentRatings.results || contentRatings.results.length === 0) return false 83 | 84 | const ratings: string[] = contentRatings.results.map((r: any) => String(r.rating).toLowerCase()) 85 | 86 | if (typeof filterCondition === "object" && filterCondition !== null) { 87 | if ("require" in filterCondition && filterCondition.require) { 88 | const req = new Set(normalizeToArray(filterCondition.require)) 89 | if (!ratings.some((r) => req.has(r))) return false 90 | } 91 | 92 | if ("include" in filterCondition && filterCondition.include) { 93 | const inc = normalizeToArray(filterCondition.include) 94 | if (!ratings.some((r) => inc.some((v) => r.includes(v)))) return false 95 | } 96 | 97 | if ("exclude" in filterCondition && filterCondition.exclude) { 98 | const exc = normalizeToArray(filterCondition.exclude) 99 | if (ratings.some((r) => exc.some((v) => r.includes(v)))) return false 100 | } 101 | 102 | return true 103 | } 104 | 105 | const vals = normalizeToArray(filterCondition) 106 | return ratings.some((r) => vals.some((v) => r.includes(v))) 107 | } 108 | 109 | /** 110 | * Finds the first filter that matches this webhook + media data and returns its `apply` target. 111 | * Prioritizes keys: keywords, contentRatings, max_seasons. 112 | */ 113 | export const findInstances = (webhook: Webhook, data: MediaData, filters: Filter[]): string | string[] | null => { 114 | try { 115 | const matchingFilter = filters.find(({ media_type, is_4k, conditions }) => { 116 | if (media_type !== webhook.media.media_type) return false 117 | if (is_4k === false && webhook.media.status !== "PENDING") return false 118 | if (is_4k === true && webhook.media.status4k !== "PENDING") return false 119 | 120 | if (!conditions || Object.keys(conditions).length === 0) return true 121 | 122 | const priorityKeys = ["keywords", "contentRatings", "max_seasons"] 123 | 124 | for (const priorityKey of priorityKeys) { 125 | if (!(priorityKey in conditions)) continue 126 | const value = (conditions as any)[priorityKey] 127 | 128 | if (priorityKey === "keywords") { 129 | if (!data.keywords) return false 130 | if (!matchKeywords(data.keywords, value as Condition)) return false 131 | 132 | if (logger.isDebugEnabled()) { 133 | logger.debug( 134 | buildDebugLogMessage("Filter check:", { 135 | Field: priorityKey, 136 | "Filter value": value, 137 | "Request value": "Keywords array (matched)", 138 | }) 139 | ) 140 | } 141 | } else if (priorityKey === "contentRatings") { 142 | if (!data.contentRatings) return false 143 | if (!matchContentRatings(data.contentRatings, value as Condition)) return false 144 | 145 | if (logger.isDebugEnabled()) { 146 | logger.debug( 147 | buildDebugLogMessage("Filter check:", { 148 | Field: priorityKey, 149 | "Filter value": value, 150 | "Request value": "Content ratings array (matched)", 151 | }) 152 | ) 153 | } 154 | } else if (priorityKey === "max_seasons" && webhook.extra) { 155 | const requestedSeasons = webhook.extra.find((item: any) => item.name === "Requested Seasons")?.value?.split(",") 156 | const max = typeof value === "number" ? value : Number.parseInt(String(value), 10) 157 | if (Number.isFinite(max) && requestedSeasons && requestedSeasons.length > max) return false 158 | } 159 | } 160 | 161 | for (const [key, value] of Object.entries(conditions)) { 162 | if (priorityKeys.includes(key)) continue 163 | 164 | const requestValue = (data as any)[key] ?? (webhook.request ? (webhook.request as any)[key] : undefined) 165 | 166 | if (requestValue === undefined || requestValue === null) { 167 | logger.debug(`Filter check skipped - Key "${key}" not found in webhook or data`) 168 | return false 169 | } 170 | 171 | if (logger.isDebugEnabled()) { 172 | logger.debug( 173 | buildDebugLogMessage("Filter check:", { 174 | Field: key, 175 | "Filter value": value, 176 | "Request value": requestValue, 177 | }) 178 | ) 179 | } 180 | 181 | if (typeof value === "object" && value !== null) { 182 | if ("require" in value && (value as any).require) { 183 | if (!matchValue((value as any).require, requestValue, true)) { 184 | logger.debug(`Filter check for required key "${key}" failed.`) 185 | return false 186 | } 187 | } 188 | 189 | if ("include" in value && (value as any).include) { 190 | if (!matchValue((value as any).include, requestValue, false)) { 191 | logger.debug(`Filter check for included key "${key}" failed.`) 192 | return false 193 | } 194 | } 195 | 196 | if ("exclude" in value && (value as any).exclude) { 197 | if (matchValue((value as any).exclude, requestValue, false)) { 198 | logger.debug(`Filter check for excluded key "${key}" matched an excluded value.`) 199 | return false 200 | } 201 | } 202 | } else { 203 | if (!matchValue(value, requestValue, false)) { 204 | logger.debug(`Filter check for key "${key}" failed.`) 205 | return false 206 | } 207 | } 208 | } 209 | 210 | return true 211 | }) 212 | 213 | if (!matchingFilter) { 214 | logger.info("No matching filter found for the current webhook") 215 | return null 216 | } 217 | 218 | logger.info(`Found matching filter at index ${filters.indexOf(matchingFilter)}`) 219 | return matchingFilter.apply 220 | } catch (error) { 221 | logger.error(`Error finding matching filter: ${error}`) 222 | return null 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "redirecterr", 6 | "dependencies": { 7 | "ajv": "^8.17.1", 8 | "js-yaml": "^4.1.0", 9 | "winston": "^3.11.0", 10 | "winston-daily-rotate-file": "^4.7.1", 11 | }, 12 | "devDependencies": { 13 | "@types/bun": "latest", 14 | "@types/js-yaml": "^4.0.9", 15 | "@types/node": "^22.15.19", 16 | }, 17 | "peerDependencies": { 18 | "typescript": "^5", 19 | }, 20 | }, 21 | }, 22 | "packages": { 23 | "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], 24 | 25 | "@dabh/diagnostics": ["@dabh/diagnostics@2.0.3", "", { "dependencies": { "colorspace": "1.1.x", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA=="], 26 | 27 | "@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="], 28 | 29 | "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], 30 | 31 | "@types/node": ["@types/node@22.15.19", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-3vMNr4TzNQyjHcRZadojpRaD9Ofr6LsonZAoQ+HMUa/9ORTPoxVIw0e0mpqWpdjj8xybyCM+oKOUH2vwFu/oEw=="], 32 | 33 | "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], 34 | 35 | "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], 36 | 37 | "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], 38 | 39 | "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], 40 | 41 | "bun-types": ["bun-types@1.2.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-rRjA1T6n7wto4gxhAO/ErZEtOXyEZEmnIHQfl0Dt1QQSB4QV0iP6BZ9/YB5fZaHFQ2dwHFrmPaRQ9GGMX01k9Q=="], 42 | 43 | "color": ["color@3.2.1", "", { "dependencies": { "color-convert": "^1.9.3", "color-string": "^1.6.0" } }, "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA=="], 44 | 45 | "color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], 46 | 47 | "color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="], 48 | 49 | "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], 50 | 51 | "colorspace": ["colorspace@1.1.4", "", { "dependencies": { "color": "^3.1.3", "text-hex": "1.0.x" } }, "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w=="], 52 | 53 | "enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="], 54 | 55 | "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], 56 | 57 | "fast-uri": ["fast-uri@3.0.6", "", {}, "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw=="], 58 | 59 | "fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="], 60 | 61 | "file-stream-rotator": ["file-stream-rotator@0.6.1", "", { "dependencies": { "moment": "^2.29.1" } }, "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ=="], 62 | 63 | "fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="], 64 | 65 | "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], 66 | 67 | "is-arrayish": ["is-arrayish@0.3.2", "", {}, "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="], 68 | 69 | "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], 70 | 71 | "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], 72 | 73 | "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], 74 | 75 | "kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="], 76 | 77 | "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], 78 | 79 | "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], 80 | 81 | "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 82 | 83 | "object-hash": ["object-hash@2.2.0", "", {}, "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="], 84 | 85 | "one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="], 86 | 87 | "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], 88 | 89 | "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], 90 | 91 | "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], 92 | 93 | "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], 94 | 95 | "simple-swizzle": ["simple-swizzle@0.2.2", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg=="], 96 | 97 | "stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="], 98 | 99 | "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], 100 | 101 | "text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="], 102 | 103 | "triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="], 104 | 105 | "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], 106 | 107 | "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], 108 | 109 | "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], 110 | 111 | "winston": ["winston@3.17.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw=="], 112 | 113 | "winston-daily-rotate-file": ["winston-daily-rotate-file@4.7.1", "", { "dependencies": { "file-stream-rotator": "^0.6.1", "object-hash": "^2.0.1", "triple-beam": "^1.3.0", "winston-transport": "^4.4.0" }, "peerDependencies": { "winston": "^3" } }, "sha512-7LGPiYGBPNyGHLn9z33i96zx/bd71pjBn9tqQzO3I4Tayv94WPmBNwKC7CO1wPHdP9uvu+Md/1nr6VSH9h0iaA=="], 114 | 115 | "winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="], 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /filters.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "bun:test" 2 | import { strictEqual } from "assert" 3 | import { findInstances } from "./src/services/filter" 4 | 5 | // --- Webhook and media data --- 6 | 7 | const movieWebhook = { 8 | notification_type: "MEDIA_AUTO_APPROVED", 9 | media: { 10 | media_type: "movie", 11 | tmdbId: "94605", 12 | tvdbId: "371028", 13 | status: "PENDING", 14 | status4k: "UNKNOWN", 15 | }, 16 | request: { 17 | request_id: "12", 18 | requestedBy_email: "email@email.com", 19 | requestedBy_username: "user2", 20 | requestedBy_avatar: "", 21 | }, 22 | extra: [], 23 | } as const 24 | 25 | const showWebhook = { 26 | notification_type: "MEDIA_AUTO_APPROVED", 27 | media: { 28 | media_type: "tv", 29 | tmdbId: "94605", 30 | tvdbId: "371028", 31 | status: "PENDING", 32 | status4k: "UNKNOWN", 33 | }, 34 | request: { 35 | request_id: "12", 36 | requestedBy_email: "email@email.com", 37 | requestedBy_username: "user2", 38 | requestedBy_avatar: "", 39 | }, 40 | extra: [{ name: "Requested Seasons", value: "0, 1, 2" }], 41 | } as const 42 | 43 | const movieGladiator2Data = { 44 | id: 558449, 45 | adult: false, 46 | budget: 310000000, 47 | genres: [ 48 | { id: 28, name: "Action" }, 49 | { id: 12, name: "Adventure" }, 50 | ], 51 | originalLanguage: "en", 52 | originalTitle: "Gladiator II", 53 | popularity: 1333.762, 54 | productionCompanies: [ 55 | { 56 | id: 4, 57 | name: "Paramount Pictures", 58 | originCountry: "US", 59 | logoPath: "/gz66EfNoYPqHTYI4q9UEN4CbHRc.png", 60 | }, 61 | { 62 | id: 14440, 63 | name: "Red Wagon Entertainment", 64 | originCountry: "US", 65 | logoPath: "/5QbaGiuxc91D6qf75JZGX6OKXoU.png", 66 | }, 67 | { 68 | id: 49325, 69 | name: "Parkes+MacDonald Image Nation", 70 | originCountry: "US", 71 | logoPath: "/R05WCoCJcPWGSDaKaYgx3AeVuR.png", 72 | }, 73 | { 74 | id: 221347, 75 | name: "Scott Free Productions", 76 | originCountry: "US", 77 | logoPath: "/6Ry6uNBaa0IbbSs1XYIgX5DkA9r.png", 78 | }, 79 | ], 80 | productionCountries: [{ iso_3166_1: "US", name: "United States of America" }], 81 | releaseDate: "2024-11-13", 82 | revenue: 0, 83 | spokenLanguages: [{ english_name: "English", iso_639_1: "en", name: "English" }], 84 | status: "Released", 85 | title: "Gladiator II", 86 | video: false, 87 | voteAverage: 7.315, 88 | voteCount: 65, 89 | backdropPath: "/8mjYwWT50GkRrrRdyHzJorfEfcl.jpg", 90 | homepage: "https://www.gladiator.movie", 91 | imdbId: "tt9218128", 92 | runtime: 148, 93 | tagline: "Prepare to be entertained.", 94 | collection: { 95 | id: 1069584, 96 | name: "Gladiator Collection", 97 | posterPath: "/r7uyUOB6fmmPumWwHiV7Hn2kUbL.jpg", 98 | backdropPath: "/eCWJHiezqeSvn0aEt1kPM6Lmlhe.jpg", 99 | }, 100 | externalIds: { 101 | facebookId: "GladiatorMovie", 102 | imdbId: "tt9218128", 103 | instagramId: "gladiatormovie", 104 | twitterId: "GladiatorMovie", 105 | }, 106 | mediaInfo: { 107 | downloadStatus: [], 108 | downloadStatus4k: [], 109 | id: 8, 110 | mediaType: "movie", 111 | tmdbId: 558449, 112 | tvdbId: null, 113 | imdbId: null, 114 | status: 3, 115 | status4k: 1, 116 | createdAt: "2024-11-14T08:19:49.000Z", 117 | updatedAt: "2024-11-14T08:19:49.000Z", 118 | lastSeasonChange: "2024-11-14T08:19:49.000Z", 119 | mediaAddedAt: null, 120 | serviceId: 0, 121 | serviceId4k: null, 122 | externalServiceId: 2, 123 | externalServiceId4k: null, 124 | externalServiceSlug: "558449", 125 | externalServiceSlug4k: null, 126 | ratingKey: null, 127 | ratingKey4k: null, 128 | requests: [] as any, 129 | issues: [], 130 | seasons: [], 131 | serviceUrl: "http://radarr:7878/movie/558449", 132 | }, 133 | watchProviders: [], 134 | keywords: [ 135 | { id: 6917, name: "epic" }, 136 | { id: 1394, name: "gladiator" }, 137 | { id: 1405, name: "roman empire" }, 138 | { id: 5049, name: "ancient rome" }, 139 | { id: 9663, name: "sequel" }, 140 | { id: 307212, name: "evil tyrant" }, 141 | { id: 317728, name: "sword and sandal" }, 142 | { id: 320529, name: "sword fighting" }, 143 | { id: 321763, name: "second part" }, 144 | ], 145 | } as const 146 | 147 | const showArcaneData = { 148 | createdBy: [ 149 | { 150 | id: 2000007, 151 | credit_id: "62d5e468c92c5d004f0d1201", 152 | name: "Christian Linke", 153 | original_name: "Christian Linke", 154 | gender: 2, 155 | profile_path: null, 156 | }, 157 | { 158 | id: 3299121, 159 | credit_id: "62d5e46e72c13e062e7196aa", 160 | name: "Alex Yee", 161 | original_name: "Alex Yee", 162 | gender: 2, 163 | profile_path: null, 164 | }, 165 | ], 166 | episodeRunTime: [], 167 | firstAirDate: "2021-11-06", 168 | genres: [ 169 | { id: 16, name: "Animation" }, 170 | { id: 10765, name: "Sci-Fi & Fantasy" }, 171 | { id: 10759, name: "Action & Adventure" }, 172 | { id: 9648, name: "Mystery" }, 173 | ], 174 | relatedVideos: [ 175 | { 176 | site: "YouTube", 177 | key: "3Svs_hl897c", 178 | name: "Final Trailer", 179 | size: 1080, 180 | type: "Trailer", 181 | url: "https://www.youtube.com/watch?v=3Svs_hl897c", 182 | }, 183 | { 184 | site: "YouTube", 185 | key: "fXmAurh012s", 186 | name: "Official Trailer", 187 | size: 1080, 188 | type: "Trailer", 189 | url: "https://www.youtube.com/watch?v=fXmAurh012s", 190 | }, 191 | ], 192 | homepage: "https://arcane.com", 193 | id: 94605, 194 | inProduction: true, 195 | languages: ["en"], 196 | lastAirDate: "2024-11-09", 197 | name: "Arcane", 198 | networks: [ 199 | { 200 | id: 213, 201 | name: "Netflix", 202 | originCountry: "", 203 | logoPath: "/wwemzKWzjKYJFfCeiB57q3r4Bcm.png", 204 | }, 205 | ], 206 | numberOfEpisodes: 18, 207 | numberOfSeasons: 2, 208 | originCountry: ["US"], 209 | originalLanguage: "en", 210 | originalName: "Arcane", 211 | tagline: "The hunt is on.", 212 | overview: 213 | "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.", 214 | popularity: 1437.972, 215 | productionCompanies: [ 216 | { 217 | id: 99496, 218 | name: "Fortiche Production", 219 | originCountry: "FR", 220 | logoPath: "/6WTCdsmIH6qR2zFVHlqjpIZhD5A.png", 221 | }, 222 | { 223 | id: 124172, 224 | name: "Riot Games", 225 | originCountry: "US", 226 | logoPath: "/sBlhznEktXKBqC87Bsfwpo1YbYR.png", 227 | }, 228 | ], 229 | productionCountries: [ 230 | { iso_3166_1: "FR", name: "France" }, 231 | { iso_3166_1: "US", name: "United States of America" }, 232 | ], 233 | contentRatings: { 234 | results: [ 235 | { descriptors: [], iso_3166_1: "US", rating: "TV-14" }, 236 | { descriptors: [], iso_3166_1: "AU", rating: "MA 15+" }, 237 | { descriptors: [], iso_3166_1: "RU", rating: "18+" }, 238 | { descriptors: [], iso_3166_1: "DE", rating: "16" }, 239 | { descriptors: [], iso_3166_1: "GB", rating: "15" }, 240 | { descriptors: [], iso_3166_1: "BR", rating: "16" }, 241 | { descriptors: [], iso_3166_1: "NL", rating: "12" }, 242 | { descriptors: [], iso_3166_1: "PT", rating: "16" }, 243 | { descriptors: [], iso_3166_1: "ES", rating: "16" }, 244 | ], 245 | }, 246 | status: "Returning Series", 247 | type: "Scripted", 248 | voteAverage: 8.8, 249 | voteCount: 4141, 250 | backdropPath: "/q8eejQcg1bAqImEV8jh8RtBD4uH.jpg", 251 | posterPath: "/abf8tHznhSvl9BAElD2cQeRr7do.jpg", 252 | externalIds: { 253 | facebookId: "arcaneshow", 254 | imdbId: "tt11126994", 255 | instagramId: "arcaneshow", 256 | tvdbId: 371028, 257 | twitterId: "arcaneshow", 258 | }, 259 | keywords: [ 260 | { id: 2343, name: "magic" }, 261 | { id: 5248, name: "female friendship" }, 262 | { id: 7947, name: "war of independence" }, 263 | { id: 14643, name: "battle" }, 264 | { id: 41645, name: "based on video game" }, 265 | { id: 146946, name: "death in family" }, 266 | { id: 161919, name: "adult animation" }, 267 | { id: 192913, name: "warrior" }, 268 | { id: 193319, name: "broken family" }, 269 | { id: 273967, name: "war" }, 270 | { id: 288793, name: "power" }, 271 | { id: 311315, name: "dramatic" }, 272 | { id: 321464, name: "intense" }, 273 | ], 274 | } as const 275 | 276 | // --- Tests --- 277 | 278 | describe("Filter Matching Tests", () => { 279 | describe("Advanced Filter Conditions Tests", () => { 280 | it("Test require condition with missing genre", () => { 281 | const filters = [ 282 | { 283 | media_type: "movie" as const, 284 | conditions: { 285 | keywords: { include: "epic" }, 286 | genres: { require: ["Action"] }, 287 | }, 288 | apply: "require-test", 289 | }, 290 | ] 291 | const result = findInstances( 292 | movieWebhook as any, 293 | { ...movieGladiator2Data, genres: [{ id: 12, name: "Adventure" }] } as any, 294 | filters as any 295 | ) 296 | strictEqual(result, null) 297 | }) 298 | 299 | it("Test exclude condition with excluded keyword present", () => { 300 | const additionalFilters = [ 301 | { 302 | media_type: "movie" as const, 303 | conditions: { 304 | keywords: { include: "epic", exclude: "horror" }, // fixed to match expectation 305 | genres: { require: "Action" }, 306 | }, 307 | apply: "require-include-test", 308 | }, 309 | ] 310 | 311 | const data = { 312 | ...movieGladiator2Data, 313 | keywords: [...movieGladiator2Data.keywords, { id: 9999, name: "horror" }], 314 | } 315 | 316 | const result = findInstances(movieWebhook as any, data as any, additionalFilters as any) 317 | strictEqual(result, null) 318 | }) 319 | 320 | it("Test include condition with partial match", () => { 321 | const filters = [ 322 | { 323 | media_type: "movie" as const, 324 | conditions: { keywords: { include: "epic" } }, 325 | apply: "include-test", 326 | }, 327 | ] 328 | const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) 329 | strictEqual(result, "include-test") 330 | }) 331 | 332 | it("Test simple string condition", () => { 333 | const simpleFilter = [ 334 | { 335 | media_type: "movie" as const, 336 | conditions: { genres: { require: "Action" } }, // require to make intent explicit 337 | apply: "simple-genre-test", 338 | }, 339 | ] 340 | const result = findInstances(movieWebhook as any, movieGladiator2Data as any, simpleFilter as any) 341 | strictEqual(result, "simple-genre-test") 342 | }) 343 | 344 | it("Test array condition", () => { 345 | const filters = [ 346 | { 347 | media_type: "movie" as const, 348 | conditions: { genres: ["Action", "Adventure"] }, 349 | apply: "array-test", 350 | }, 351 | ] 352 | const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) 353 | strictEqual(result, "array-test") 354 | }) 355 | 356 | it("Test object condition with include", () => { 357 | const includeFilter = [ 358 | { 359 | media_type: "movie" as const, 360 | conditions: { genres: { include: "Action" } }, 361 | apply: "include-test", 362 | }, 363 | ] 364 | const result = findInstances(movieWebhook as any, movieGladiator2Data as any, includeFilter as any) 365 | strictEqual(result, "include-test") 366 | }) 367 | 368 | it("Test multiple exclude conditions", () => { 369 | const filters = [ 370 | { 371 | media_type: "movie" as const, 372 | conditions: { keywords: { exclude: ["horror", "anime", "romance"] } }, 373 | apply: "exclude-test", 374 | }, 375 | ] 376 | const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) 377 | strictEqual(result, "exclude-test") 378 | }) 379 | 380 | it("Test exclude with one matching condition", () => { 381 | const filters = [ 382 | { 383 | media_type: "movie" as const, 384 | conditions: { keywords: { exclude: ["horror", "fantasy"] } }, 385 | apply: "exclude-test", 386 | }, 387 | ] 388 | const data = { 389 | ...movieGladiator2Data, 390 | keywords: [...movieGladiator2Data.keywords, { id: 9999, name: "fantasy" }], 391 | } 392 | const result = findInstances(movieWebhook as any, data as any, filters as any) 393 | strictEqual(result, null) 394 | }) 395 | 396 | it("Test keywords with include, require, and exclude in one condition", () => { 397 | const filters = [ 398 | { 399 | media_type: "movie" as const, 400 | conditions: { 401 | keywords: { include: "gladiator", require: "epic", exclude: "horror" }, 402 | }, 403 | apply: "complex-keyword-test", 404 | }, 405 | ] 406 | const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) 407 | strictEqual(result, "complex-keyword-test") 408 | }) 409 | 410 | it("Test multiple condition types across different fields", () => { 411 | const filters = [ 412 | { 413 | media_type: "movie" as const, 414 | conditions: { 415 | keywords: { include: "epic" }, 416 | genres: { require: "Action" }, 417 | originalLanguage: "en", 418 | }, 419 | apply: "multi-test", 420 | }, 421 | ] 422 | const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) 423 | strictEqual(result, "multi-test") 424 | }) 425 | 426 | it("Test complex condition with all types", () => { 427 | const filters = [ 428 | { 429 | media_type: "movie" as const, 430 | conditions: { 431 | keywords: { include: "epic", exclude: "horror" }, 432 | genres: { require: "Action" }, 433 | originalLanguage: "en", 434 | }, 435 | apply: "complex-test", 436 | }, 437 | ] 438 | const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) 439 | strictEqual(result, "complex-test") 440 | }) 441 | 442 | it("Test complex condition with negative case", () => { 443 | const filters = [ 444 | { 445 | media_type: "movie" as const, 446 | conditions: { 447 | keywords: { include: "epic", exclude: "horror" }, 448 | genres: { require: "Action" }, 449 | originalLanguage: "fr", 450 | }, 451 | apply: "complex-test", 452 | }, 453 | ] 454 | const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) 455 | strictEqual(result, null) 456 | }) 457 | }) 458 | 459 | it("Test movie filter with exclude keyword", () => { 460 | const filters = [ 461 | { 462 | media_type: "movie" as const, 463 | conditions: { originalLanguage: "en" }, 464 | apply: "lang-test", 465 | }, 466 | ] 467 | const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) 468 | strictEqual(result, "lang-test") 469 | }) 470 | 471 | it("Exclude movie with keyword", () => { 472 | const filters = [ 473 | { 474 | media_type: "movie" as const, 475 | conditions: { keywords: { exclude: "epic" } }, 476 | apply: "exclude-test", 477 | }, 478 | ] 479 | const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) 480 | strictEqual(result, null) 481 | }) 482 | 483 | it("Match show with language only", () => { 484 | const filters = [ 485 | { 486 | media_type: "tv" as const, 487 | conditions: { originalLanguage: "en" }, 488 | apply: "lang-test", 489 | }, 490 | ] 491 | const result = findInstances(showWebhook as any, showArcaneData as any, filters as any) 492 | strictEqual(result, "lang-test") 493 | }) 494 | 495 | it("Test keyword exclusion", () => { 496 | const filters = [ 497 | { 498 | media_type: "movie" as const, 499 | conditions: { keywords: { exclude: "epic" }, originalLanguage: "en" }, 500 | apply: "exclude-test", 501 | }, 502 | ] 503 | const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) 504 | strictEqual(result, null) 505 | }) 506 | 507 | it("Test keyword matching", () => { 508 | const filters = [ 509 | { 510 | media_type: "movie" as const, 511 | conditions: { keywords: "power" }, 512 | apply: "keyword-test", 513 | }, 514 | ] 515 | const result = findInstances( 516 | movieWebhook as any, 517 | { 518 | ...movieGladiator2Data, 519 | keywords: [{ id: 1, name: "power" }], 520 | } as any, 521 | filters as any 522 | ) 523 | strictEqual(result, "keyword-test") 524 | }) 525 | 526 | it("Match movie based on age rating", () => { 527 | const filters = [ 528 | { 529 | media_type: "movie" as const, 530 | conditions: { contentRatings: "16" }, 531 | apply: "rating-test", 532 | }, 533 | ] 534 | const result = findInstances( 535 | movieWebhook as any, 536 | { 537 | ...movieGladiator2Data, 538 | contentRatings: { results: [{ rating: "16" }] }, 539 | } as any, 540 | filters as any 541 | ) 542 | strictEqual(result, "rating-test") 543 | }) 544 | 545 | it("Handle non-matching cases gracefully", () => { 546 | const filters = [ 547 | { 548 | media_type: "movie" as const, 549 | conditions: { 550 | keywords: "epic", 551 | genres: ["Adventure", "Drama"], 552 | originalLanguage: "pl", 553 | }, 554 | apply: "non-match-test", 555 | }, 556 | ] 557 | const result = findInstances(movieWebhook as any, movieGladiator2Data as any, filters as any) 558 | strictEqual(result, null) 559 | }) 560 | 561 | it("Match a complex filter with mixed types (strings, arrays)", () => { 562 | const filters = [ 563 | { 564 | media_type: "tv" as const, 565 | conditions: { 566 | keywords: ["power", "dramatic"], 567 | genres: ["Sci-Fi & Fantasy", "Animation"], 568 | }, 569 | apply: "complex-mixed-test", 570 | }, 571 | ] 572 | const result = findInstances(showWebhook as any, showArcaneData as any, filters as any) 573 | strictEqual(result, "complex-mixed-test") 574 | }) 575 | }) 576 | --------------------------------------------------------------------------------