├── 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 |
--------------------------------------------------------------------------------