Compare commits

..

18 Commits

Author SHA1 Message Date
fallenbagel
1a4b8c1563 chore(i18n): re-extract locales 2026-02-16 10:00:40 +08:00
fallenbagel
747b84d4c9 feat(routing): implemet deletion of associated routing rules when sonarr instance removed 2026-02-16 09:56:39 +08:00
fallenbagel
be58352925 feat(routing): add validation for ruleIds in reorder endpoint 2026-02-16 09:53:59 +08:00
fallenbagel
e43777527e chore(i18n): extract locales 2026-02-16 09:53:58 +08:00
fallenbagel
87dddbb879 feat: replace override rules with routing rules system
Replaces the override rule system with a new priority-based routing rules engine. Routing rules are
evaluated top-to-bottom with first-match-wins semantics, supporting conditions on users, genres,
languages, and keywords. Quality profiles, root folders, minimum availability, series type, and tags
move from instance-level settings to routing rules with support for instance switching, with
fallback rules acting as catch-all defaults. Includes a migration to convert existing instance
defaults and override rules into the new system, a routing resolver used at request time, updated
OpenAPI spec, and a new UI with drag-and-drop reordering, filter tabs, and inline rule expansion.

fix #232, fix #1560, fix #2058
2026-02-16 09:53:47 +08:00
Ludovic Ortega
04b9d87174 fix(helm): add "v" as prefix for appVersion tag (#2445)
Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2026-02-15 15:40:34 +01:00
fallenbagel
b499976902 fix(servarr): increase default API timeout from 5000ms to 10000ms (#2442) 2026-02-15 17:19:27 +05:00
Ludovic Ortega
87fb0dfd6c chore(helm): update ghcr.io/seerr-team/seerr ( 3.0.0 → 3.0.1 ) [skip-ci] (#2441) 2026-02-15 11:59:01 +00:00
mjonkus
b6a913211a docs: add Unraid installation and migration guide (#2440)
Co-authored-by: Mindaugas Jonkus <mjonkusgmail.com>

[skip ci]
2026-02-15 16:56:37 +05:00
Ludovic Ortega
39ae32f509 docs: fix migration guide title (#2425)
Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2026-02-15 03:41:57 +01:00
Ludovic Ortega
c2977f6430 ci(changelog): fix changelog template (#2431) 2026-02-15 00:35:05 +01:00
Ludovic Ortega
92504b7864 ci(release): disable verify attestations for now (#2420)
Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2026-02-14 19:17:54 +01:00
Gauthier
018e04a657 docs: remove warning about Seerr not being released (#2411) 2026-02-14 18:17:34 +01:00
Gauthier
e503de323a chore: upgrade PWA version (#2418) 2026-02-14 17:09:55 +00:00
Gauthier
bcd8002887 fix: run the blocklist migration last (#2417) 2026-02-14 16:26:03 +01:00
Conlan Kreher
33a5d9a9ac refactor: rename blacklist to blocklist (#2157)
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
Co-authored-by: fallenbagel <98979876+Fallenbagel@users.noreply.github.com>
Co-authored-by: 0xsysr3ll <0xsysr3ll@pm.me>
Co-authored-by: gauthier-th <mail@gauthierth.fr>
2026-02-14 14:31:45 +01:00
fallenbagel
15be3d7475 fix(base-scanner): derive media availability from actual season state… (#2412) 2026-02-13 23:05:59 +05:00
Gauthier
55c2d541e6 feat(ui): rebrand Jellyseerr logos to Seerr (#2406) 2026-02-13 17:37:00 +01:00
137 changed files with 4426 additions and 3658 deletions

4
.github/cliff.toml vendored
View File

@@ -33,9 +33,9 @@ body = """
{{ self::print_commit(commit=commit) }}
{%- endfor %}
{%- for commit in commits %}
{%- if not commit.scope -%}
{%- if not commit.scope %}
{{ self::print_commit(commit=commit) }}
{%- endif -%}
{%- endif %}
{%- endfor -%}
{%- endfor -%}

View File

@@ -16,7 +16,7 @@
description: 'Update appVersion in Chart.yaml to match Docker image',
fileMatch: ['(^|/)Chart\\.yaml$'],
matchStrings: [
'#\\s+renovate:\\s+image=(?<depName>\\S*)\nappVersion:\\s+"(?<currentValue>\\S*)"',
"#\\s+renovate:\\s+image=(?<depName>\\S*)\nappVersion:\\s+'(?<currentValue>\\S*)'",
],
datasourceTemplate: 'docker',
},

View File

@@ -6,7 +6,7 @@ on:
workflow_dispatch:
push:
branches:
- legacy-jellyseerr
- develop
paths:
- 'docs/**'
- 'gen-docs/**'

View File

@@ -279,17 +279,17 @@ jobs:
--certificate-identity "https://github.com/${{ github.workflow_ref }}" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
- name: Verify attestations
run: |
cosign verify-attestation "ghcr.io/${{ github.repository }}@${{ needs.publish.outputs.image_digest }}" \
--type cyclonedx \
--certificate-identity "https://github.com/${{ github.workflow_ref }}" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" > /dev/null
# - name: Verify attestations
# run: |
# cosign verify-attestation "ghcr.io/${{ github.repository }}@${{ needs.publish.outputs.image_digest }}" \
# --type cyclonedx \
# --certificate-identity "https://github.com/${{ github.workflow_ref }}" \
# --certificate-oidc-issuer "https://token.actions.githubusercontent.com" > /dev/null
cosign verify-attestation "${{ env.DOCKER_HUB }}@${{ needs.publish.outputs.image_digest }}" \
--type cyclonedx \
--certificate-identity "https://github.com/${{ github.workflow_ref }}" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" > /dev/null
# cosign verify-attestation "${{ env.DOCKER_HUB }}@${{ needs.publish.outputs.image_digest }}" \
# --type cyclonedx \
# --certificate-identity "https://github.com/${{ github.workflow_ref }}" \
# --certificate-oidc-issuer "https://token.actions.githubusercontent.com" > /dev/null
publish-release:
name: Publish release

View File

@@ -1,6 +1,6 @@
<div align="center">⚠️ <strong>NOTE:</strong> We are currently in the process of merging Overseerr and Jellyseerr into this unified repository.</div>
<h1 align="center" style="font-size: 4em;">🚧 Seerr</h1>
<p align="center">
<img src="./public/logo_full.svg" alt="Seerr" style="margin: 20px 0;">
</p>
<p align="center">
<img src="https://github.com/seerr-team/seerr/actions/workflows/release.yml/badge.svg" alt="Seerr Release" />
<img src="https://github.com/seerr-team/seerr/actions/workflows/ci.yml/badge.svg" alt="Seerr CI">
@@ -26,37 +26,25 @@
- Granular permission system.
- Support for various notification agents.
- Mobile-friendly design, for when you need to approve requests on the go!
- Support for watchlisting & blacklisting media.
- Support for watchlisting & blocklisting media.
With more features on the way! Check out our [issue tracker](/../../issues) to see the features which have already been requested.
## Getting Started
For instructions on how to install and run **Jellyseerr**, please refer to the official documentation:
Check out our documentation for instructions on how to install and run Seerr:
https://docs.seerr.dev/getting-started/
> [!IMPORTANT]
> **Seerr is not officially released yet.**
> The project is currently available **only on the `develop` branch** and is intended for **beta testing only**.
The documentation linked above is for running the **latest Jellyseerr** release.
> [!WARNING]
> If you are migrating from **Overseerr** to **Seerr** for beta testing, **do not follow the Jellyseerr latest setup guide**.
Instead, follow the dedicated migration guide (with `:develop` tag):
https://github.com/seerr-team/seerr/blob/develop/docs/migration-guide.mdx
> [!CAUTION]
> **DO NOT run Jellyseerr (latest) using an existing Overseerr database. This includes third-party images with `seerr:latest` (as it points to jellyseerr 2.7.3 and not seerr.**
> Doing so **may cause database corruption and/or irreversible data loss and/or weird unintended behaviour**.
For migration assistance, beta testing questions, or troubleshooting, please join our **Discord** and ask for support there.
## Preview
<img src="./public/preview.jpg">
<img src="./public/preview.jpg" alt="Seerr application preview" />
## Migrating from Overseerr/Jellyseerr to Seerr
Read our [release announcement](https://docs.seerr.dev/blog/seerr-release) to learn what Seerr means for Jellyseerr and Overseerr users.
Please follow our [migration guide](https://docs.seerr.dev/migration-guide) for detailed instructions on migrating from Overseerr or Jellyseerr.
## Support

View File

@@ -3,9 +3,9 @@ kubeVersion: '>=1.23.0-0'
name: seerr-chart
description: Seerr helm chart for Kubernetes
type: application
version: 3.0.0
version: 3.0.2
# renovate: image=ghcr.io/seerr-team/seerr
appVersion: '3.0.0'
appVersion: 'v3.0.1'
maintainers:
- name: Seerr Team
url: https://github.com/orgs/seerr-team/people

View File

@@ -1,6 +1,6 @@
# seerr-chart
![Version: 3.0.0](https://img.shields.io/badge/Version-3.0.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 3.0.0](https://img.shields.io/badge/AppVersion-3.0.0-informational?style=flat-square)
![Version: 3.0.2](https://img.shields.io/badge/Version-3.0.2-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v3.0.1](https://img.shields.io/badge/AppVersion-v3.0.1-informational?style=flat-square)
Seerr helm chart for Kubernetes

View File

@@ -18,8 +18,8 @@
"discoverRegion": "",
"streamingRegion": "",
"originalLanguage": "",
"blacklistedTags": "",
"blacklistedTagsLimit": 50,
"blocklistedTags": "",
"blocklistedTagsLimit": 50,
"trustProxy": false,
"mediaServerType": 1,
"partialRequestsEnabled": true,

View File

@@ -23,7 +23,7 @@ Welcome to the Seerr Documentation.
- Localization into other languages.
- Support for **PostgreSQL** and **SQLite** databases.
- Support for various notification agents.
- Easily **Watchlist** or **Blacklist** media.
- Easily **Watchlist** or **Blocklist** media.
- More features to come!
## We need your help!

View File

@@ -5,12 +5,7 @@ sidebar_position: 3
---
# Unraid
:::danger
This method has not yet been updated for Seerr and is awaiting a community contribution.
Feel free to open a pull request on GitHub to update this installation method.
:::
<!--
:::warning
Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages.
:::
@@ -19,9 +14,68 @@ Third-party installation methods are maintained by the community. The Seerr team
This method is not recommended for most users. It is intended for advanced users who are using Unraid.
:::
1. Ensure you have the **Community Applications** plugin installed.
2. Inside the **Community Applications** app store, search for **Seerr**.
3. Click the **Install Button**.
4. On the following **Add Container** screen, make changes to the **Host Port** and **Host Path 1** \(Appdata\) as needed.
5. Click apply and access "Seerr" at your `<ServerIP:HostPort>` in a web browser.
-->
If an official Unraid Community Applications template for Seerr isn't available in your catalog, you can install Seerr manually using Unraid's Docker UI.
## Fresh Installation
### 1. Create the config directory
Open the Unraid terminal and run:
```bash
mkdir -p /mnt/user/appdata/seerr
chown -R 1000:1000 /mnt/user/appdata/seerr
```
### 2. Add the Docker container
Navigate to the **Docker** tab in Unraid and click **Add Container**. Fill in the following:
| Field | Value |
|---|---|
| **Name** | `seerr` |
| **Repository** | `ghcr.io/seerr-team/seerr:latest` |
| **Registry URL** (optional) | `https://ghcr.io` |
| **Icon URL** | `https://raw.githubusercontent.com/seerr-team/seerr/develop/public/android-chrome-512x512.png` |
| **WebUI** | `http://[IP]:[PORT:5055]` |
| **Extra Parameters** | `--init` |
| **Network Type** | `bridge` |
| **Privileged** | `Off` |
Then click **Add another Path, Port, Variable** to add:
**Port:**
| Field | Value |
|---|---|
| Container Port | `5055` |
| Host Port | `5055` |
| Connection Type | `TCP` |
**Path:**
| Field | Value |
|---|---|
| Container Path | `/app/config` |
| Host Path | `/mnt/user/appdata/seerr` |
**Variable:**
| Field | Value |
|---|---|
| Key | `TZ` |
| Value | Your [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (e.g., `America/New_York`) |
**Variable (optional):**
| Field | Value |
|---|---|
| Key | `LOG_LEVEL` |
| Value | `info` |
Click **Apply** to create and start the container.
### 3. Access Seerr
Open the WebUI at `http://<your-unraid-ip>:5055` and follow the setup wizard.
:::info
The `--init` flag in **Extra Parameters** is required. Seerr does not include its own init process, so `--init` ensures proper signal handling and clean container shutdowns.
:::

View File

@@ -23,7 +23,6 @@ Installation methods are now divided into two categories: official and third-par
The Seerr team is only responsible for official installation methods, while third-party methods are maintained by the community.
Some methods are currently not maintained, but this does not mean they are permanently discontinued. The community may restore and support them if they choose to do so.
- **Unraid app:** Not maintained
- **Snap package:** Not maintained
:::
@@ -200,14 +199,82 @@ Summary of changes :
</TabItem>
</Tabs>
### Nix (Third-party installation methods)
## Third-party installation methods
### Nix
Waiting for https://github.com/NixOS/nixpkgs/pull/450096 and https://github.com/NixOS/nixpkgs/pull/450093
### AUR (Third-party installation methods)
### AUR
See https://aur.archlinux.org/packages/seerr
### TrueNAS (Third-party installation methods)
### TrueNAS
Waiting for https://github.com/truenas/apps/issues/3374
### Unraid
Refer to [Seerr Unraid Documentation](/getting-started/third-parties/unraid), all of our examples have been updated to reflect the below change.
Seerr will automatically migrate your existing Overseerr or Jellyseerr data on first startup. No manual database migration is needed.
1. Stop and remove the old Overseerr (or Jellyseerr) container from the Unraid **Docker** tab. Click the container icon, then **Stop**, then **Remove**. **⚠️ Do not delete the appdata folder ⚠️**
2. Back up your existing appdata folder:
```bash
cp -a /mnt/user/appdata/overseerr /mnt/user/appdata/overseerr-backup
```
3. Fix config folder permissions — Seerr runs as the `node` user (UID 1000) instead of root:
```bash
chown -R 1000:1000 /mnt/user/appdata/overseerr
```
For Jellyseerr users, replace `overseerr` with `jellyseerr` in the path above.
4. Add a new container in the Unraid **Docker** tab. Click **Add Container** and fill in the following:
| Field | Value |
|---|---|
| **Name** | `seerr` |
| **Repository** | `ghcr.io/seerr-team/seerr:latest` |
| **Registry URL** (optional) | `https://ghcr.io` |
| **Icon URL** | `https://raw.githubusercontent.com/seerr-team/seerr/develop/public/android-chrome-512x512.png` |
| **WebUI** | `http://[IP]:[PORT:5055]` |
| **Extra Parameters** | `--init` |
| **Network Type** | `bridge` |
| **Privileged** | `Off` |
Then click **Add another Path, Port, Variable** to add:
**Port:**
| Field | Value |
|---|---|
| Container Port | `5055` |
| Host Port | `5055` |
| Connection Type | `TCP` |
**Path** — point this to your existing config folder:
| Field | Value |
|---|---|
| Container Path | `/app/config` |
| Host Path | `/mnt/user/appdata/overseerr` |
For Jellyseerr users, use `/mnt/user/appdata/jellyseerr`.
**Variable:**
| Field | Value |
|---|---|
| Key | `TZ` |
| Value | Your [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) (e.g., `America/New_York`) |
**Variable (optional):**
| Field | Value |
|---|---|
| Key | `LOG_LEVEL` |
| Value | `info` |
5. Click **Apply** to start the container. Check the container logs to confirm the automatic migration completed successfully.
:::tip
If you are using a reverse proxy (such as SWAG or Nginx Proxy Manager), update your proxy configuration to point to the new container name `seerr`. The default port remains `5055`.
:::

View File

@@ -13,7 +13,7 @@ These settings are stored in the `settings.json` file located in the Seerr data
## User Data
Apart from the settings, all other data—including user accounts, media requests, blacklist etc. are stored in the database (either SQLite or PostgreSQL).
Apart from the settings, all other data—including user accounts, media requests, blocklist etc. are stored in the database (either SQLite or PostgreSQL).
# Backup

View File

@@ -62,13 +62,13 @@ Set the default display language for Seerr. Users can override this setting in t
These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. The Streaming Region filters the available streaming providers on the media page. Users can override these global settings by configuring these same options in their user settings.
## Blacklist Content with Tags and Limit Content Blacklisted per Tag
## Blocklist Content with Tags and Limit Content Blocklisted per Tag
These settings blacklist any TV shows or movies that have one of the entered tags. The "Process Blacklisted Tags" job adds entries to the blacklist based on the configured blacklisted tags. If a blacklisted tag is removed, any media blacklisted under that tag will be removed from the blacklist when the "Process Blacklisted Tags" job runs.
These settings blocklist any TV shows or movies that have one of the entered tags. The "Process Blocklisted Tags" job adds entries to the blocklist based on the configured blocklisted tags. If a blocklisted tag is removed, any media blocklisted under that tag will be removed from the blocklist when the "Process Blocklisted Tags" job runs.
The limit setting determines how many pages per tag the job will process, with each page containing 20 entries. The job cycles through all 16 available discovery sort options, querying the defined number of pages to blacklist media that is most likely to appear at the top of each sort. Higher limits will create a more accurate blacklist, but will require more storage.
The limit setting determines how many pages per tag the job will process, with each page containing 20 entries. The job cycles through all 16 available discovery sort options, querying the defined number of pages to blocklist media that is most likely to appear at the top of each sort. Higher limits will create a more accurate blocklist, but will require more storage.
Blacklisted tags are disabled until at least one tag is entered. These settings cannot be overridden in user settings.
Blocklisted tags are disabled until at least one tag is entered. These settings cannot be overridden in user settings.
## Hide Available Media
@@ -78,9 +78,9 @@ Available media will still appear in search results, however, so it is possible
This setting is **disabled** by default.
## Hide Blacklisted Items
## Hide Blocklisted Items
When enabled, media that has been blacklisted will not appear on the "Discover" home page, for all administrators. This can be useful to hide content that you don't want to see, such as content with specific tags or content that has been manually blacklisted when you have the "Manage Blacklist" permission.
When enabled, media that has been blocklisted will not appear on the "Discover" home page, for all administrators. This can be useful to hide content that you don't want to see, such as content with specific tags or content that has been manually blocklisted when you have the "Manage Blocklist" permission.
This setting is **disabled** by default.

View File

@@ -21,7 +21,7 @@ Seerr brings several features that were previously available in Jellyseerr but m
* **Alternative media solution:** Added support for Jellyfin and Emby in addition to the existing Plex integration.
* **PostgreSQL support**: In addition to SQLite, you can now opt in to using a PostgreSQL database.
* **Blacklist for movies, series, and tags**: Allows permitted users to hide movies, series, or tags from regular users.
* **Blocklist for movies, series, and tags**: Allows permitted users to hide movies, series, or tags from regular users.
* **Override rules**: Adjust default request settings based on conditions such as user, tag, or other criteria.
* **TVDB metadata**: Option to use TheTVDB metadata for series (as in Sonarr) instead of TMDB.
* **DNS caching**: Reduces lookup times and external requests, especially useful when using systems like Pi-Hole/Adguard Home.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 821 B

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 193 KiB

View File

@@ -3,7 +3,7 @@
// previously cached resources to be updated from the network.
// This variable is intentionally declared and unused.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const OFFLINE_VERSION = 4;
const OFFLINE_VERSION = 5;
const CACHE_NAME = 'offline';
// Customize this with a different URL if needed.
const OFFLINE_URL = '/offline.html';

View File

@@ -38,8 +38,8 @@ tags:
description: Endpoints related to getting service (Radarr/Sonarr) details.
- name: watchlist
description: Collection of media to watch later
- name: blacklist
description: Blacklisted media from discovery page.
- name: blocklist
description: Blocklisted media from discovery page.
servers:
- url: '{server}/api/v1'
variables:
@@ -48,7 +48,7 @@ servers:
components:
schemas:
Blacklist:
Blocklist:
type: object
properties:
tmdbId:
@@ -577,21 +577,9 @@ components:
example: false
baseUrl:
type: string
activeProfileId:
type: number
example: 1
activeProfileName:
type: string
example: 720p/1080p
activeDirectory:
type: string
example: '/movies'
is4k:
type: boolean
example: false
minimumAvailability:
type: string
example: 'In Cinema'
isDefault:
type: boolean
example: false
@@ -610,11 +598,7 @@ components:
- port
- apiKey
- useSsl
- activeProfileId
- activeProfileName
- activeDirectory
- is4k
- minimumAvailability
- isDefault
SonarrSettings:
type: object
@@ -640,31 +624,6 @@ components:
example: false
baseUrl:
type: string
activeProfileId:
type: number
example: 1
activeProfileName:
type: string
example: 720p/1080p
activeDirectory:
type: string
example: '/tv/'
activeLanguageProfileId:
type: number
example: 1
activeAnimeProfileId:
type: number
nullable: true
activeAnimeLanguageProfileId:
type: number
nullable: true
activeAnimeProfileName:
type: string
example: 720p/1080p
nullable: true
activeAnimeDirectory:
type: string
nullable: true
is4k:
type: boolean
example: false
@@ -689,9 +648,6 @@ components:
- port
- apiKey
- useSsl
- activeProfileId
- activeProfileName
- activeDirectory
- is4k
- enableSeasonFolders
- isDefault
@@ -2083,11 +2039,138 @@ components:
type: string
native_name:
type: string
OverrideRule:
RoutingRule:
type: object
properties:
id:
type: number
readOnly: true
name:
type: string
example: 'Anime Content'
serviceType:
type: string
enum:
- radarr
- sonarr
is4k:
type: boolean
priority:
type: number
users:
type: string
nullable: true
description: Comma-separated user IDs
genres:
type: string
nullable: true
description: Comma-separated genre IDs
languages:
type: string
nullable: true
description: Pipe-separated language codes (e.g. "ja|ko")
keywords:
type: string
nullable: true
description: Comma-separated keyword IDs
targetServiceId:
type: number
description: ID of the target Radarr/Sonarr instance
activeProfileId:
type: number
nullable: true
rootFolder:
type: string
nullable: true
minimumAvailability:
type: string
nullable: true
enum:
- announced
- inCinemas
- released
- null
seriesType:
type: string
nullable: true
enum:
- standard
- daily
- anime
- null
tags:
type: string
nullable: true
description: Comma-separated tag IDs
isFallback:
type: boolean
createdAt:
type: string
format: date-time
readOnly: true
updatedAt:
type: string
format: date-time
readOnly: true
RoutingRuleRequest:
type: object
required:
- name
- serviceType
- targetServiceId
properties:
name:
type: string
serviceType:
type: string
enum:
- radarr
- sonarr
is4k:
type: boolean
priority:
type: number
users:
type: string
nullable: true
genres:
type: string
nullable: true
languages:
type: string
nullable: true
keywords:
type: string
nullable: true
targetServiceId:
type: number
activeProfileId:
type: number
nullable: true
minimumAvailability:
type: string
nullable: true
enum:
- announced
- inCinemas
- released
- null
rootFolder:
type: string
nullable: true
seriesType:
type: string
nullable: true
enum:
- standard
- daily
- anime
- null
tags:
type: string
nullable: true
isFallback:
type: boolean
Certification:
type: object
properties:
@@ -4529,12 +4612,12 @@ paths:
restricted:
type: boolean
example: false
/blacklist:
/blocklist:
get:
summary: Returns blacklisted items
description: Returns list of all blacklisted media
summary: Returns blocklisted items
description: Returns list of all blocklisted media
tags:
- settings
- blocklist
parameters:
- in: query
name: take
@@ -4558,11 +4641,11 @@ paths:
name: filter
schema:
type: string
enum: [all, manual, blacklistedTags]
enum: [all, manual, blocklistedTags]
default: manual
responses:
'200':
description: Blacklisted items returned
description: Blocklisted items returned
content:
application/json:
schema:
@@ -4593,25 +4676,25 @@ paths:
type: number
example: 438631
post:
summary: Add media to blacklist
summary: Add media to blocklist
tags:
- blacklist
- blocklist
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Blacklist'
$ref: '#/components/schemas/Blocklist'
responses:
'201':
description: Item succesfully blacklisted
description: Item succesfully blocklisted
'412':
description: Item has already been blacklisted
/blacklist/{tmdbId}:
description: Item has already been blocklisted
/blocklist/{tmdbId}:
get:
summary: Get media from blacklist
summary: Get media from blocklist
tags:
- blacklist
- blocklist
parameters:
- in: path
name: tmdbId
@@ -4622,11 +4705,131 @@ paths:
type: string
responses:
'200':
description: Blacklist details in JSON
description: Blocklist details in JSON
delete:
summary: Remove media from blacklist
summary: Remove media from blocklist
tags:
- blacklist
- blocklist
parameters:
- in: path
name: tmdbId
description: tmdbId ID
required: true
example: '1'
schema:
type: string
responses:
'204':
description: Succesfully removed media item
/blacklist:
get:
summary: Returns blocklisted items
description: |
**DEPRECATED**: Use `/blocklist` instead. This endpoint will be deprecated soon.
deprecated: true
tags:
- blocklist
parameters:
- in: query
name: take
schema:
type: number
nullable: true
example: 25
- in: query
name: skip
schema:
type: number
nullable: true
example: 0
- in: query
name: search
schema:
type: string
nullable: true
example: dune
- in: query
name: filter
schema:
type: string
enum: [all, manual, blocklistedTags]
default: manual
responses:
'200':
description: Blocklisted items returned
content:
application/json:
schema:
type: object
properties:
pageInfo:
$ref: '#/components/schemas/PageInfo'
results:
type: array
items:
type: object
properties:
user:
$ref: '#/components/schemas/User'
createdAt:
type: string
example: 2024-04-21T01:55:44.000Z
id:
type: number
example: 1
mediaType:
type: string
example: movie
title:
type: string
example: Dune
tmdbId:
type: number
example: 438631
post:
summary: Add media to blocklist
description: |
**DEPRECATED**: Use `/blocklist` instead. This endpoint will be deprecated soon.
deprecated: true
tags:
- blocklist
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Blocklist'
responses:
'201':
description: Item succesfully blocklisted
'412':
description: Item has already been blocklisted
/blacklist/{tmdbId}:
get:
summary: Get media from blocklist
description: |
**DEPRECATED**: Use `/blocklist/{tmdbId}` instead. This endpoint will be deprecated soon.
deprecated: true
tags:
- blocklist
parameters:
- in: path
name: tmdbId
description: tmdbId ID
required: true
example: '1'
schema:
type: string
responses:
'200':
description: Blocklist details in JSON
delete:
summary: Remove media from blocklist
description: |
**DEPRECATED**: Use `/blocklist/{tmdbId}` instead. This endpoint will be deprecated soon.
deprecated: true
tags:
- blocklist
parameters:
- in: path
name: tmdbId
@@ -7687,41 +7890,72 @@ paths:
message:
type: string
example: Unable to retrieve TV certifications.
/overrideRule:
/routingRule:
get:
summary: Get override rules
description: Returns a list of all override rules with their conditions and settings
summary: Get all routing rules
description: Returns all routing rules ordered by priority (highest first).
tags:
- overriderule
- settings
responses:
'200':
description: Override rules returned
description: Routing rules returned
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/OverrideRule'
$ref: '#/components/schemas/RoutingRule'
post:
summary: Create override rule
description: Creates a new Override Rule from the request body.
summary: Create a new routing rule
description: Creates a new routing rule. Priority is auto-assigned (highest existing + 10).
tags:
- overriderule
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoutingRuleRequest'
responses:
'200':
description: 'Values were successfully created'
'201':
description: Routing rule created
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/OverrideRule'
/overrideRule/{ruleId}:
$ref: '#/components/schemas/RoutingRule'
/routingRule/{ruleId}:
put:
summary: Update override rule
description: Updates an Override Rule from the request body.
summary: Update a routing rule
description: Updates an existing routing rule by ID.
tags:
- overriderule
- settings
parameters:
- in: path
name: ruleId
required: true
schema:
type: number
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoutingRuleRequest'
responses:
'200':
description: Routing rule updated
content:
application/json:
schema:
$ref: '#/components/schemas/RoutingRule'
'404':
description: Routing rule not found
delete:
summary: Delete a routing rule
description: Deletes a routing rule by ID.
tags:
- settings
parameters:
- in: path
name: ruleId
@@ -7730,31 +7964,42 @@ paths:
type: number
responses:
'200':
description: 'Values were successfully updated'
description: Routing rule deleted
content:
application/json:
schema:
$ref: '#/components/schemas/RoutingRule'
'404':
description: Routing rule not found
/routingRule/reorder:
post:
summary: Reorder routing rules
description: Bulk update priorities by providing an ordered list of rule IDs (highest priority first).
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- ruleIds
properties:
ruleIds:
type: array
items:
type: number
responses:
'200':
description: Rules reordered
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/OverrideRule'
delete:
summary: Delete override rule by ID
description: Deletes the override rule with the provided ruleId.
tags:
- overriderule
parameters:
- in: path
name: ruleId
required: true
schema:
type: number
responses:
'200':
description: Override rule successfully deleted
content:
application/json:
schema:
$ref: '#/components/schemas/OverrideRule'
$ref: '#/components/schemas/RoutingRule'
security:
- cookieAuth: []
- apiKey: []

View File

@@ -92,7 +92,7 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
apiKey,
cacheName,
apiName,
timeout = 5000,
timeout = 10000,
}: {
url: string;
apiKey: string;

View File

@@ -17,6 +17,6 @@ export enum MediaStatus {
PROCESSING,
PARTIALLY_AVAILABLE,
AVAILABLE,
BLACKLISTED,
BLOCKLISTED,
DELETED,
}

View File

@@ -2,7 +2,7 @@ import { MediaStatus, type MediaType } from '@server/constants/media';
import dataSource from '@server/datasource';
import Media from '@server/entity/Media';
import { User } from '@server/entity/User';
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
import type { BlocklistItem } from '@server/interfaces/api/blocklistInterfaces';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import type { EntityManager } from 'typeorm';
import {
@@ -19,7 +19,7 @@ import type { ZodNumber, ZodOptional, ZodString } from 'zod';
@Entity()
@Unique(['tmdbId'])
export class Blacklist implements BlacklistItem {
export class Blocklist implements BlocklistItem {
@PrimaryGeneratedColumn()
public id: number;
@@ -38,65 +38,65 @@ export class Blacklist implements BlacklistItem {
})
user?: User;
@OneToOne(() => Media, (media) => media.blacklist, {
@OneToOne(() => Media, (media) => media.blocklist, {
onDelete: 'CASCADE',
})
@JoinColumn()
public media: Media;
@Column({ nullable: true, type: 'varchar' })
public blacklistedTags?: string;
public blocklistedTags?: string;
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;
constructor(init?: Partial<Blacklist>) {
constructor(init?: Partial<Blocklist>) {
Object.assign(this, init);
}
public static async addToBlacklist(
public static async addToBlocklist(
{
blacklistRequest,
blocklistRequest,
}: {
blacklistRequest: {
blocklistRequest: {
mediaType: MediaType;
title?: ZodOptional<ZodString>['_output'];
tmdbId: ZodNumber['_output'];
blacklistedTags?: string;
blocklistedTags?: string;
};
},
entityManager?: EntityManager
): Promise<void> {
const em = entityManager ?? dataSource;
const blacklist = new this({
...blacklistRequest,
const blocklist = new this({
...blocklistRequest,
});
const mediaRepository = em.getRepository(Media);
let media = await mediaRepository.findOne({
where: {
tmdbId: blacklistRequest.tmdbId,
tmdbId: blocklistRequest.tmdbId,
},
});
const blacklistRepository = em.getRepository(this);
const blocklistRepository = em.getRepository(this);
await blacklistRepository.save(blacklist);
await blocklistRepository.save(blocklist);
if (!media) {
media = new Media({
tmdbId: blacklistRequest.tmdbId,
status: MediaStatus.BLACKLISTED,
status4k: MediaStatus.BLACKLISTED,
mediaType: blacklistRequest.mediaType,
blacklist: Promise.resolve(blacklist),
tmdbId: blocklistRequest.tmdbId,
status: MediaStatus.BLOCKLISTED,
status4k: MediaStatus.BLOCKLISTED,
mediaType: blocklistRequest.mediaType,
blocklist: Promise.resolve(blocklist),
});
await mediaRepository.save(media);
} else {
media.blacklist = Promise.resolve(blacklist);
media.status = MediaStatus.BLACKLISTED;
media.status4k = MediaStatus.BLACKLISTED;
media.blocklist = Promise.resolve(blocklist);
media.status = MediaStatus.BLOCKLISTED;
media.status4k = MediaStatus.BLOCKLISTED;
await mediaRepository.save(media);
}

View File

@@ -3,7 +3,7 @@ import SonarrAPI from '@server/api/servarr/sonarr';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist';
import { Blocklist } from '@server/entity/Blocklist';
import type { User } from '@server/entity/User';
import { Watchlist } from '@server/entity/Watchlist';
import type { DownloadingItem } from '@server/lib/downloadtracker';
@@ -126,8 +126,8 @@ class Media {
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
public issues: Issue[];
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
public blacklist: Promise<Blacklist>;
@OneToOne(() => Blocklist, (blocklist) => blocklist.media)
public blocklist: Promise<Blocklist>;
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;

View File

@@ -1,5 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import {
MediaRequestStatus,
@@ -7,10 +6,10 @@ import {
MediaType,
} from '@server/constants/media';
import { getRepository } from '@server/datasource';
import OverrideRule from '@server/entity/OverrideRule';
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions';
import { resolveRoute } from '@server/lib/routingResolver';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
@@ -35,7 +34,7 @@ export class RequestPermissionError extends Error {}
export class QuotaRestrictedError extends Error {}
export class DuplicateMediaRequestError extends Error {}
export class NoSeasonsAvailableError extends Error {}
export class BlacklistedMediaError extends Error {}
export class BlocklistedMediaError extends Error {}
type MediaRequestOptions = {
isAutoRequest?: boolean;
@@ -140,14 +139,14 @@ export class MediaRequest {
mediaType: requestBody.mediaType,
});
} else {
if (media.status === MediaStatus.BLACKLISTED) {
logger.warn('Request for media blocked due to being blacklisted', {
if (media.status === MediaStatus.BLOCKLISTED) {
logger.warn('Request for media blocked due to being blocklisted', {
tmdbId: tmdbMedia.id,
mediaType: requestBody.mediaType,
label: 'Media Request',
});
throw new BlacklistedMediaError('This media is blacklisted.');
throw new BlocklistedMediaError('This media is blocklisted.');
}
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
@@ -202,133 +201,41 @@ export class MediaRequest {
}
}
// Apply overrides if the user is not an admin or has the "advanced request" permission
const useOverrides = !user.hasPermission([Permission.MANAGE_REQUESTS], {
// apply routing rules to determine request settings (server/profile/folder/tags)
let tmdbKeywords: number[] = [];
if ('keywords' in tmdbMedia.keywords) {
tmdbKeywords = tmdbMedia.keywords.keywords.map((k: TmdbKeyword) => k.id);
} else if ('results' in tmdbMedia.keywords) {
tmdbKeywords = tmdbMedia.keywords.results.map((k: TmdbKeyword) => k.id);
}
const isAdmin = user.hasPermission([Permission.MANAGE_REQUESTS], {
type: 'or',
});
let rootFolder = requestBody.rootFolder;
let profileId = requestBody.profileId;
let tags = requestBody.tags;
const route = await resolveRoute({
serviceType:
requestBody.mediaType === MediaType.MOVIE ? 'radarr' : 'sonarr',
is4k: requestBody.is4k ?? false,
userId: requestUser.id,
genres: tmdbMedia.genres.map((g) => g.id),
language: tmdbMedia.original_language,
keywords: tmdbKeywords,
});
if (useOverrides) {
const defaultRadarrId = requestBody.is4k
? settings.radarr.findIndex((r) => r.is4k && r.isDefault)
: settings.radarr.findIndex((r) => !r.is4k && r.isDefault);
const defaultSonarrId = requestBody.is4k
? settings.sonarr.findIndex((s) => s.is4k && s.isDefault)
: settings.sonarr.findIndex((s) => !s.is4k && s.isDefault);
const overrideRuleRepository = getRepository(OverrideRule);
const overrideRules = await overrideRuleRepository.find({
where:
requestBody.mediaType === MediaType.MOVIE
? { radarrServiceId: defaultRadarrId }
: { sonarrServiceId: defaultSonarrId },
});
const appliedOverrideRules = overrideRules.filter((rule) => {
const hasAnimeKeyword =
'results' in tmdbMedia.keywords &&
tmdbMedia.keywords.results.some(
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
);
// Skip override rules if the media is an anime TV show as anime TV
// is handled by default and override rules do not explicitly include
// the anime keyword
if (
requestBody.mediaType === MediaType.TV &&
hasAnimeKeyword &&
(!rule.keywords ||
!rule.keywords.split(',').map(Number).includes(ANIME_KEYWORD_ID))
) {
return false;
}
if (
rule.users &&
!rule.users
.split(',')
.some((userId) => Number(userId) === requestUser.id)
) {
return false;
}
if (
rule.genre &&
!rule.genre
.split(',')
.some((genreId) =>
tmdbMedia.genres
.map((genre) => genre.id)
.includes(Number(genreId))
)
) {
return false;
}
if (
rule.language &&
!rule.language
.split('|')
.some((languageId) => languageId === tmdbMedia.original_language)
) {
return false;
}
if (
rule.keywords &&
!rule.keywords.split(',').some((keywordId) => {
let keywordList: TmdbKeyword[] = [];
if ('keywords' in tmdbMedia.keywords) {
keywordList = tmdbMedia.keywords.keywords;
} else if ('results' in tmdbMedia.keywords) {
keywordList = tmdbMedia.keywords.results;
}
return keywordList
.map((keyword: TmdbKeyword) => keyword.id)
.includes(Number(keywordId));
})
) {
return false;
}
return true;
});
// hacky way to prioritize rules
// TODO: make this better
const prioritizedRule = appliedOverrideRules.sort((a, b) => {
const keys: (keyof OverrideRule)[] = ['genre', 'language', 'keywords'];
const aSpecificity = keys.filter((key) => a[key] !== null).length;
const bSpecificity = keys.filter((key) => b[key] !== null).length;
// Take the rule with the most specific condition first
return bSpecificity - aSpecificity;
})[0];
if (prioritizedRule) {
if (prioritizedRule.rootFolder) {
rootFolder = prioritizedRule.rootFolder;
}
if (prioritizedRule.profileId) {
profileId = prioritizedRule.profileId;
}
if (prioritizedRule.tags) {
tags = [
...new Set([
...(tags || []),
...prioritizedRule.tags.split(',').map((tag) => Number(tag)),
]),
];
}
logger.debug('Override rule applied.', {
label: 'Media Request',
overrides: prioritizedRule,
});
}
}
const serverId =
isAdmin && requestBody.serverId != null
? requestBody.serverId
: route.serviceId;
const profileId =
isAdmin && requestBody.profileId != null
? requestBody.profileId
: route.profileId;
const rootFolder =
isAdmin && requestBody.rootFolder
? requestBody.rootFolder
: route.rootFolder;
const tags = isAdmin && requestBody.tags ? requestBody.tags : route.tags;
if (requestBody.mediaType === MediaType.MOVIE) {
await mediaRepository.save(media);
@@ -367,7 +274,7 @@ export class MediaRequest {
? user
: undefined,
is4k: requestBody.is4k,
serverId: requestBody.serverId,
serverId: serverId,
profileId: profileId,
rootFolder: rootFolder,
tags: tags,
@@ -477,7 +384,7 @@ export class MediaRequest {
? user
: undefined,
is4k: requestBody.is4k,
serverId: requestBody.serverId,
serverId: serverId,
profileId: profileId,
rootFolder: rootFolder,
languageProfileId: requestBody.languageProfileId,

View File

@@ -0,0 +1,69 @@
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity()
class RoutingRule {
@PrimaryGeneratedColumn()
public id: number;
@Column({ type: 'varchar' })
public name: string;
@Column({ type: 'varchar' })
public serviceType: 'radarr' | 'sonarr';
@Column({ default: false })
public is4k: boolean;
@Column({ type: 'int', default: 0 })
public priority: number;
@Column({ nullable: true })
public users?: string;
@Column({ nullable: true })
public genres?: string;
@Column({ nullable: true })
public languages?: string;
@Column({ nullable: true })
public keywords?: string;
@Column({ type: 'int' })
public targetServiceId: number;
@Column({ type: 'int', nullable: true })
public activeProfileId?: number;
@Column({ nullable: true })
public rootFolder?: string;
@Column({ nullable: true })
public seriesType?: string;
@Column({ nullable: true })
public tags?: string;
@Column({ type: 'varchar', nullable: true })
public minimumAvailability?: 'announced' | 'inCinemas' | 'released';
@Column({ default: false })
public isFallback: boolean;
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
public createdAt: Date;
@DbAwareColumn({
type: 'datetime',
default: () => 'CURRENT_TIMESTAMP',
onUpdate: 'CURRENT_TIMESTAMP',
})
public updatedAt: Date;
constructor(init?: Partial<RoutingRule>) {
Object.assign(this, init);
}
}
export default RoutingRule;

View File

@@ -1,15 +1,15 @@
import type { User } from '@server/entity/User';
import type { PaginatedResponse } from '@server/interfaces/api/common';
export interface BlacklistItem {
export interface BlocklistItem {
tmdbId: number;
mediaType: 'movie' | 'tv';
title?: string;
createdAt?: Date;
user?: User;
blacklistedTags?: string;
blocklistedTags?: string;
}
export interface BlacklistResultsResponse extends PaginatedResponse {
results: BlacklistItem[];
export interface BlocklistResultsResponse extends PaginatedResponse {
results: BlocklistItem[];
}

View File

@@ -30,7 +30,7 @@ export interface PublicSettingsResponse {
applicationTitle: string;
applicationUrl: string;
hideAvailable: boolean;
hideBlacklisted: boolean;
hideBlocklisted: boolean;
localLogin: boolean;
mediaServerLogin: boolean;
movie4kEnabled: boolean;

View File

@@ -6,7 +6,7 @@ import type {
} from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import dataSource from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist';
import { Blocklist } from '@server/entity/Blocklist';
import Media from '@server/entity/Media';
import type {
RunnableScanner,
@@ -20,7 +20,7 @@ import type { EntityManager } from 'typeorm';
const TMDB_API_DELAY_MS = 250;
class AbortTransaction extends Error {}
class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
class BlocklistedTagProcessor implements RunnableScanner<StatusBase> {
private running = false;
private progress = 0;
private total = 0;
@@ -30,12 +30,12 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
try {
await dataSource.transaction(async (em) => {
await this.cleanBlacklist(em);
await this.createBlacklistEntries(em);
await this.cleanBlocklist(em);
await this.createBlocklistEntries(em);
});
} catch (err) {
if (err instanceof AbortTransaction) {
logger.info('Aborting job: Process Blacklisted Tags', {
logger.info('Aborting job: Process Blocklisted Tags', {
label: 'Jobs',
});
} else {
@@ -64,37 +64,37 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
this.cancel();
}
private async createBlacklistEntries(em: EntityManager) {
private async createBlocklistEntries(em: EntityManager) {
const tmdb = createTmdbWithRegionLanguage();
const settings = getSettings();
const blacklistedTags = settings.main.blacklistedTags;
const blacklistedTagsArr = blacklistedTags.split(',');
const blocklistedTags = settings.main.blocklistedTags;
const blocklistedTagsArr = blocklistedTags.split(',');
const pageLimit = settings.main.blacklistedTagsLimit;
const pageLimit = settings.main.blocklistedTagsLimit;
const invalidKeywords = new Set<string>();
if (blacklistedTags.length === 0) {
if (blocklistedTags.length === 0) {
return;
}
// The maximum number of queries we're expected to execute
this.total =
2 * blacklistedTagsArr.length * pageLimit * SortOptionsIterable.length;
2 * blocklistedTagsArr.length * pageLimit * SortOptionsIterable.length;
for (const type of [MediaType.MOVIE, MediaType.TV]) {
const getDiscover =
type === MediaType.MOVIE ? tmdb.getDiscoverMovies : tmdb.getDiscoverTv;
// Iterate for each tag
for (const tag of blacklistedTagsArr) {
for (const tag of blocklistedTagsArr) {
const keywordDetails = await tmdb.getKeywordDetails({
keywordId: Number(tag),
});
if (keywordDetails === null) {
logger.warn('Skipping invalid keyword in blacklisted tags', {
label: 'Blacklisted Tags Processor',
logger.warn('Skipping invalid keyword in blocklisted tags', {
label: 'Blocklisted Tags Processor',
keywordId: tag,
});
invalidKeywords.add(tag);
@@ -134,8 +134,8 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
queryMax = response.total_pages;
}
} catch (error) {
logger.error('Error processing keyword in blacklisted tags', {
label: 'Blacklisted Tags Processor',
logger.error('Error processing keyword in blocklisted tags', {
label: 'Blocklisted Tags Processor',
keywordId: tag,
errorMessage: error.message,
});
@@ -145,19 +145,19 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
}
if (invalidKeywords.size > 0) {
const currentTags = blacklistedTagsArr.filter(
const currentTags = blocklistedTagsArr.filter(
(tag) => !invalidKeywords.has(tag)
);
const cleanedTags = currentTags.join(',');
if (cleanedTags !== blacklistedTags) {
settings.main.blacklistedTags = cleanedTags;
if (cleanedTags !== blocklistedTags) {
settings.main.blocklistedTags = cleanedTags;
await settings.save();
logger.info('Cleaned up invalid keywords from settings', {
label: 'Blacklisted Tags Processor',
label: 'Blocklisted Tags Processor',
removedKeywords: Array.from(invalidKeywords),
newBlacklistedTags: cleanedTags,
newBlocklistedTags: cleanedTags,
});
}
}
@@ -169,33 +169,33 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
mediaType: MediaType,
em: EntityManager
) {
const blacklistRepository = em.getRepository(Blacklist);
const blocklistRepository = em.getRepository(Blocklist);
for (const entry of response.results) {
const blacklistEntry = await blacklistRepository.findOne({
const blocklistEntry = await blocklistRepository.findOne({
where: { tmdbId: entry.id },
});
if (blacklistEntry) {
// Don't mark manual blacklists with tags
// If media wasn't previously blacklisted for this tag, add the tag to the media's blacklist
if (blocklistEntry) {
// Don't mark manual blocklists with tags
// If media wasn't previously blocklisted for this tag, add the tag to the media's blocklist
if (
blacklistEntry.blacklistedTags &&
!blacklistEntry.blacklistedTags.includes(`,${keywordId},`)
blocklistEntry.blocklistedTags &&
!blocklistEntry.blocklistedTags.includes(`,${keywordId},`)
) {
await blacklistRepository.update(blacklistEntry.id, {
blacklistedTags: `${blacklistEntry.blacklistedTags}${keywordId},`,
await blocklistRepository.update(blocklistEntry.id, {
blocklistedTags: `${blocklistEntry.blocklistedTags}${keywordId},`,
});
}
} else {
// Media wasn't previously blacklisted, add it to the blacklist
await Blacklist.addToBlacklist(
// Media wasn't previously blocklisted, add it to the blocklist
await Blocklist.addToBlocklist(
{
blacklistRequest: {
blocklistRequest: {
mediaType,
title: 'title' in entry ? entry.title : entry.name,
tmdbId: entry.id,
blacklistedTags: `,${keywordId},`,
blocklistedTags: `,${keywordId},`,
},
},
em
@@ -204,22 +204,22 @@ class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
}
}
private async cleanBlacklist(em: EntityManager) {
// Remove blacklist and media entries blacklisted by tags
private async cleanBlocklist(em: EntityManager) {
// Remove blocklist and media entries blocklisted by tags
const mediaRepository = em.getRepository(Media);
const mediaToRemove = await mediaRepository
.createQueryBuilder('media')
.innerJoinAndSelect(Blacklist, 'blist', 'blist.tmdbId = media.tmdbId')
.where(`blist.blacklistedTags IS NOT NULL`)
.innerJoinAndSelect(Blocklist, 'blist', 'blist.tmdbId = media.tmdbId')
.where(`blist.blocklistedTags IS NOT NULL`)
.getMany();
// Batch removes so the query doesn't get too large
for (let i = 0; i < mediaToRemove.length; i += 500) {
await mediaRepository.remove(mediaToRemove.slice(i, i + 500)); // This also deletes the blacklist entries via cascading
await mediaRepository.remove(mediaToRemove.slice(i, i + 500)); // This also deletes the blocklist entries via cascading
}
}
}
const blacklistedTagsProcessor = new BlacklistedTagProcessor();
const blocklistedTagsProcessor = new BlocklistedTagProcessor();
export default blacklistedTagsProcessor;
export default blocklistedTagsProcessor;

View File

@@ -1,5 +1,5 @@
import { MediaServerType } from '@server/constants/server';
import blacklistedTagsProcessor from '@server/job/blacklistedTagsProcessor';
import blocklistedTagsProcessor from '@server/job/blocklistedTagsProcessor';
import availabilitySync from '@server/lib/availabilitySync';
import downloadTracker from '@server/lib/downloadtracker';
import ImageProxy from '@server/lib/imageproxy';
@@ -239,19 +239,19 @@ export const startJobs = (): void => {
});
scheduledJobs.push({
id: 'process-blacklisted-tags',
name: 'Process Blacklisted Tags',
id: 'process-blocklisted-tags',
name: 'Process Blocklisted Tags',
type: 'process',
interval: 'days',
cronSchedule: jobs['process-blacklisted-tags'].schedule,
job: schedule.scheduleJob(jobs['process-blacklisted-tags'].schedule, () => {
logger.info('Starting scheduled job: Process Blacklisted Tags', {
cronSchedule: jobs['process-blocklisted-tags'].schedule,
job: schedule.scheduleJob(jobs['process-blocklisted-tags'].schedule, () => {
logger.info('Starting scheduled job: Process Blocklisted Tags', {
label: 'Jobs',
});
blacklistedTagsProcessor.run();
blocklistedTagsProcessor.run();
}),
running: () => blacklistedTagsProcessor.status().running,
cancelFn: () => blacklistedTagsProcessor.cancel(),
running: () => blocklistedTagsProcessor.status().running,
cancelFn: () => blocklistedTagsProcessor.cancel(),
});
logger.info('Scheduled jobs loaded', { label: 'Jobs' });

View File

@@ -27,8 +27,8 @@ export enum Permission {
AUTO_REQUEST_TV = 33554432,
RECENT_VIEW = 67108864,
WATCHLIST_VIEW = 134217728,
MANAGE_BLACKLIST = 268435456,
VIEW_BLACKLIST = 1073741824,
MANAGE_BLOCKLIST = 268435456,
VIEW_BLOCKLIST = 1073741824,
}
export interface PermissionCheckOptions {

View File

@@ -0,0 +1,136 @@
import { getRepository } from '@server/datasource';
import RoutingRule from '@server/entity/RoutingRule';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
export interface ResolvedRoute {
serviceId: number;
profileId?: number;
rootFolder?: string;
seriesType?: string;
tags?: number[];
minimumAvailability?: string;
}
interface RouteParams {
serviceType: 'radarr' | 'sonarr';
is4k: boolean;
userId: number;
genres: number[];
language: string;
keywords: number[];
}
/**
* Evaluates routing rules top-to-bottom (by priority DESC).
* First match wins. Falls back to the default instance if no rules match.
*/
export async function resolveRoute(
params: RouteParams
): Promise<ResolvedRoute> {
const routingRuleRepository = getRepository(RoutingRule);
const settings = getSettings();
const rules = await routingRuleRepository.find({
where: {
serviceType: params.serviceType,
is4k: params.is4k,
},
order: { priority: 'DESC' },
});
for (const rule of rules) {
if (matchesAllConditions(rule, params)) {
logger.debug('Routing rule matched', {
label: 'Routing',
ruleId: rule.id,
ruleName: rule.name,
targetServiceId: rule.targetServiceId,
});
return {
serviceId: rule.targetServiceId,
profileId: rule.activeProfileId ?? undefined,
rootFolder: rule.rootFolder ?? undefined,
seriesType: rule.seriesType ?? undefined,
tags: rule.tags ? rule.tags.split(',').map(Number) : undefined,
minimumAvailability: rule.minimumAvailability ?? undefined,
};
}
}
logger.warn(
'No routing rules matched (including fallback rules). Falling back to settings default.',
{
label: 'Routing',
serviceType: params.serviceType,
is4k: params.is4k,
}
);
const services =
params.serviceType === 'radarr' ? settings.radarr : settings.sonarr;
const defaultServiceIdx = services.findIndex(
(s) => (params.is4k ? s.is4k : !s.is4k) && s.isDefault
);
if (defaultServiceIdx === -1) {
throw new Error(
`No default ${params.serviceType} instance configured for ${
params.is4k ? '4K' : 'non-4K'
} content.`
);
}
return { serviceId: services[defaultServiceIdx].id };
}
/**
* Check if a rule's conditions all match the request parameters.
*
* - No conditions (fallback) = always matches
* - AND between condition types (all populated conditions must pass)
* - OR within a condition type (any value can match)
*/
function matchesAllConditions(rule: RoutingRule, params: RouteParams): boolean {
if (rule.isFallback) {
return true;
}
const hasConditions =
rule.users || rule.genres || rule.languages || rule.keywords;
if (!hasConditions) {
return true;
}
if (rule.users) {
const ruleUserIds = rule.users.split(',').map(Number);
if (!ruleUserIds.includes(params.userId)) {
return false;
}
}
if (rule.genres) {
const ruleGenreIds = rule.genres.split(',').map(Number);
if (!ruleGenreIds.some((g) => params.genres.includes(g))) {
return false;
}
}
if (rule.languages) {
const ruleLangs = rule.languages.split('|');
if (!ruleLangs.includes(params.language)) {
return false;
}
}
if (rule.keywords) {
const ruleKeywordIds = rule.keywords.split(',').map(Number);
if (!ruleKeywordIds.some((k) => params.keywords.includes(k))) {
return false;
}
}
return true;
}

View File

@@ -132,15 +132,15 @@ export interface MainSettings {
tv: Quota;
};
hideAvailable: boolean;
hideBlacklisted: boolean;
hideBlocklisted: boolean;
localLogin: boolean;
mediaServerLogin: boolean;
newPlexLogin: boolean;
discoverRegion: string;
streamingRegion: string;
originalLanguage: string;
blacklistedTags: string;
blacklistedTagsLimit: number;
blocklistedTags: string;
blocklistedTagsLimit: number;
mediaServerType: number;
partialRequestsEnabled: boolean;
enableSpecialEpisodes: boolean;
@@ -181,7 +181,7 @@ interface FullPublicSettings extends PublicSettings {
applicationTitle: string;
applicationUrl: string;
hideAvailable: boolean;
hideBlacklisted: boolean;
hideBlocklisted: boolean;
localLogin: boolean;
mediaServerLogin: boolean;
movie4kEnabled: boolean;
@@ -346,7 +346,7 @@ export type JobId =
| 'jellyfin-full-scan'
| 'image-cache-cleanup'
| 'availability-sync'
| 'process-blacklisted-tags';
| 'process-blocklisted-tags';
export interface AllSettings {
clientId: string;
@@ -389,15 +389,15 @@ class Settings {
tv: {},
},
hideAvailable: false,
hideBlacklisted: false,
hideBlocklisted: false,
localLogin: true,
mediaServerLogin: true,
newPlexLogin: true,
discoverRegion: '',
streamingRegion: '',
originalLanguage: '',
blacklistedTags: '',
blacklistedTagsLimit: 50,
blocklistedTags: '',
blocklistedTagsLimit: 50,
mediaServerType: MediaServerType.NOT_CONFIGURED,
partialRequestsEnabled: true,
enableSpecialEpisodes: false,
@@ -570,7 +570,7 @@ class Settings {
'image-cache-cleanup': {
schedule: '0 0 5 * * *',
},
'process-blacklisted-tags': {
'process-blocklisted-tags': {
schedule: '0 30 1 */7 * *',
},
},
@@ -671,7 +671,7 @@ class Settings {
applicationTitle: this.data.main.applicationTitle,
applicationUrl: this.data.main.applicationUrl,
hideAvailable: this.data.main.hideAvailable,
hideBlacklisted: this.data.main.hideBlacklisted,
hideBlocklisted: this.data.main.hideBlocklisted,
localLogin: this.data.main.localLogin,
mediaServerLogin: this.data.main.mediaServerLogin,
jellyfinExternalHost: this.data.jellyfin.externalHostname,

View File

@@ -0,0 +1,40 @@
import type { AllSettings } from '@server/lib/settings';
const migrateBlacklistToBlocklist = (settings: any): AllSettings => {
if (
Array.isArray(settings.migrations) &&
settings.migrations.includes('0008_migrate_blacklist_to_blocklist')
) {
return settings;
}
if (settings.main?.hideBlacklisted !== undefined) {
settings.main.hideBlocklisted = settings.main.hideBlacklisted;
delete settings.main.hideBlacklisted;
}
if (settings.main?.blacklistedTags !== undefined) {
settings.main.blocklistedTags = settings.main.blacklistedTags;
delete settings.main.blacklistedTags;
}
if (settings.main?.blacklistedTagsLimit !== undefined) {
settings.main.blocklistedTagsLimit = settings.main.blacklistedTagsLimit;
delete settings.main.blacklistedTagsLimit;
}
if (settings.jobs?.['process-blacklisted-tags']) {
settings.jobs['process-blocklisted-tags'] =
settings.jobs['process-blacklisted-tags'];
delete settings.jobs['process-blacklisted-tags'];
}
if (!Array.isArray(settings.migrations)) {
settings.migrations = [];
}
settings.migrations.push('0008_migrate_blacklist_to_blocklist');
return settings;
};
export default migrateBlacklistToBlocklist;

View File

@@ -0,0 +1,187 @@
import { getRepository } from '@server/datasource';
import OverrideRule from '@server/entity/OverrideRule';
import RoutingRule from '@server/entity/RoutingRule';
import type { AllSettings } from '@server/lib/settings';
const ANIME_KEYWORD_ID = '210024';
const migrateToRoutingRules = async (settings: any): Promise<AllSettings> => {
if (
Array.isArray(settings.migrations) &&
settings.migrations.includes('0009_migrate_to_routing_rules')
) {
return settings;
}
const routingRuleRepo = getRepository(RoutingRule);
let errorOccurred = false;
for (const radarr of settings.radarr || []) {
if (!radarr.isDefault) continue;
try {
await routingRuleRepo.save(
new RoutingRule({
name: `${radarr.name} Default Route`,
serviceType: 'radarr',
targetServiceId: radarr.id,
is4k: radarr.is4k,
isFallback: true,
priority: 0,
activeProfileId: radarr.activeProfileId || undefined,
rootFolder: radarr.activeDirectory || undefined,
minimumAvailability: radarr.minimumAvailability || 'released',
tags:
radarr.tags && radarr.tags.length > 0
? radarr.tags.join(',')
: undefined,
})
);
} catch (error) {
console.error(
`Failed to create Radarr fallback routing rule for "${radarr.name}".`,
error.message
);
errorOccurred = true;
}
}
for (const sonarr of settings.sonarr || []) {
if (!sonarr.isDefault) continue;
try {
await routingRuleRepo.save(
new RoutingRule({
name: `${sonarr.name} Default Route`,
serviceType: 'sonarr',
targetServiceId: sonarr.id,
is4k: sonarr.is4k,
isFallback: true,
priority: 0,
activeProfileId: sonarr.activeProfileId || undefined,
rootFolder: sonarr.activeDirectory || undefined,
seriesType: sonarr.seriesType || 'standard',
tags:
sonarr.tags && sonarr.tags.length > 0
? sonarr.tags.join(',')
: undefined,
})
);
} catch (error) {
console.error(
`Failed to create Sonarr fallback routing rule for "${sonarr.name}".`,
error.message
);
errorOccurred = true;
}
const hasAnimeOverrides =
sonarr.activeAnimeProfileId ||
sonarr.activeAnimeDirectory ||
(sonarr.animeTags && sonarr.animeTags.length > 0);
if (hasAnimeOverrides) {
try {
await routingRuleRepo.save(
new RoutingRule({
name: 'Anime',
serviceType: 'sonarr',
targetServiceId: sonarr.id,
is4k: sonarr.is4k,
isFallback: false,
priority: 10,
keywords: ANIME_KEYWORD_ID,
activeProfileId:
sonarr.activeAnimeProfileId ||
sonarr.activeProfileId ||
undefined,
rootFolder:
sonarr.activeAnimeDirectory ||
sonarr.activeDirectory ||
undefined,
seriesType: sonarr.animeSeriesType || 'anime',
tags:
sonarr.animeTags && sonarr.animeTags.length > 0
? sonarr.animeTags.join(',')
: undefined,
})
);
} catch (error) {
console.error(
`Failed to create Sonarr anime routing rule for "${sonarr.name}".`,
error.message
);
errorOccurred = true;
}
}
}
let overrideRules: OverrideRule[] = [];
try {
const overrideRuleRepo = getRepository(OverrideRule);
overrideRules = await overrideRuleRepo.find();
} catch {
// If the OverrideRule table doesn't exist or can't be queried, we can skip this step.
}
let priority = 20;
for (const rule of overrideRules) {
const isRadarr = rule.radarrServiceId != null;
const serviceType: 'radarr' | 'sonarr' = isRadarr ? 'radarr' : 'sonarr';
const serviceIndex = isRadarr
? rule.radarrServiceId!
: rule.sonarrServiceId!;
const services =
serviceType === 'radarr' ? settings.radarr || [] : settings.sonarr || [];
const targetService = services[serviceIndex];
if (!targetService) {
console.error(
`Skipping override rule #${rule.id}: ${serviceType} instance at index ${serviceIndex} not found in settings.`
);
errorOccurred = true;
continue;
}
try {
await routingRuleRepo.save(
new RoutingRule({
name: `Migrated Rule #${rule.id}`,
serviceType,
targetServiceId: targetService.id,
is4k: targetService.is4k,
isFallback: false,
priority,
users: rule.users || undefined,
genres: rule.genre || undefined,
languages: rule.language || undefined,
keywords: rule.keywords || undefined,
activeProfileId: rule.profileId || undefined,
rootFolder: rule.rootFolder || undefined,
tags: rule.tags || undefined,
})
);
priority += 10;
} catch (error) {
console.error(
`Failed to migrate override rule #${rule.id} to routing rule.`,
error.message
);
errorOccurred = true;
}
}
if (!errorOccurred) {
if (!Array.isArray(settings.migrations)) {
settings.migrations = [];
}
settings.migrations.push('0009_migrate_to_routing_rules');
}
return settings;
};
export default migrateToRoutingRules;

View File

@@ -3,7 +3,7 @@ import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import {
BlacklistedMediaError,
BlocklistedMediaError,
DuplicateMediaRequestError,
MediaRequest,
NoSeasonsAvailableError,
@@ -145,8 +145,8 @@ class WatchlistSync {
errorMessage: e.message,
});
break;
// Blacklisted media should be silently ignored during watchlist sync to avoid spam
case BlacklistedMediaError:
// Blocklisted media should be silently ignored during watchlist sync to avoid spam
case BlocklistedMediaError:
break;
default:
logger.error('Failed to create media request from watchlist', {

View File

@@ -0,0 +1,49 @@
import logger from '@server/logger';
import type { NextFunction, Request, Response } from 'express';
interface DeprecationOptions {
oldPath: string;
newPath: string;
sunsetDate?: string;
documentationUrl?: string;
}
/**
* Mark an API route as deprecated.
* @see https://datatracker.ietf.org/doc/html/rfc8594
*/
export const deprecatedRoute = ({
oldPath,
newPath,
sunsetDate,
documentationUrl,
}: DeprecationOptions) => {
return (req: Request, res: Response, next: NextFunction) => {
logger.warn(
`Deprecated API endpoint accessed: ${oldPath} → use ${newPath} instead`,
{
label: 'API Deprecation',
ip: req.ip,
userAgent: req.get('User-Agent'),
method: req.method,
path: req.originalUrl,
}
);
res.setHeader('Deprecation', 'true');
const links: string[] = [`<${newPath}>; rel="successor-version"`];
if (documentationUrl) {
links.push(`<${documentationUrl}>; rel="deprecation"`);
}
res.setHeader('Link', links.join(', '));
if (sunsetDate) {
res.setHeader('Sunset', new Date(sunsetDate).toUTCString());
}
next();
};
};
export default deprecatedRoute;

View File

@@ -0,0 +1,19 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class RenameBlacklistToBlocklist1771080196816 implements MigrationInterface {
name = 'RenameBlacklistToBlocklist1771080196816';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "blacklist" RENAME TO "blocklist"`);
await queryRunner.query(
`ALTER TABLE "blocklist" RENAME COLUMN "blacklistedTags" TO "blocklistedTags"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "blocklist" RENAME COLUMN "blocklistedTags" TO "blacklistedTags"`
);
await queryRunner.query(`ALTER TABLE "blocklist" RENAME TO "blacklist"`);
}
}

View File

@@ -0,0 +1,66 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class RenameBlacklistToBlocklist1771080196816 implements MigrationInterface {
name = 'RenameBlacklistToBlocklist1771080196816';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "temporary_blocklist" (
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"mediaType" varchar NOT NULL,
"title" varchar,
"tmdbId" integer NOT NULL,
"blocklistedTags" varchar,
"createdAt" datetime NOT NULL DEFAULT (datetime('now')),
"userId" integer,
"mediaId" integer,
CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"),
CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"),
CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
)
`);
await queryRunner.query(`
INSERT INTO "temporary_blocklist" ("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId")
SELECT "id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId" FROM "blacklist"
`);
await queryRunner.query(`DROP TABLE "blacklist"`);
await queryRunner.query(
`ALTER TABLE "temporary_blocklist" RENAME TO "blocklist"`
);
await queryRunner.query(
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blocklist" ("tmdbId")`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "blocklist" RENAME TO "blacklist"`);
await queryRunner.query(`
CREATE TABLE "temporary_blacklist" (
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
"mediaType" varchar NOT NULL,
"title" varchar,
"tmdbId" integer NOT NULL,
"blacklistedTags" varchar,
"createdAt" datetime NOT NULL DEFAULT (datetime('now')),
"userId" integer,
"mediaId" integer,
CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"),
CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"),
CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION,
CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
)
`);
await queryRunner.query(`
INSERT INTO "temporary_blacklist" ("id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId")
SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "blacklist"
`);
await queryRunner.query(`DROP TABLE "blacklist"`);
await queryRunner.query(
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
);
await queryRunner.query(
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId")`
);
}
}

View File

@@ -1,8 +1,8 @@
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist';
import { Blocklist } from '@server/entity/Blocklist';
import Media from '@server/entity/Media';
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
import type { BlocklistResultsResponse } from '@server/interfaces/api/blocklistInterfaces';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
@@ -10,53 +10,53 @@ import { Router } from 'express';
import { EntityNotFoundError, QueryFailedError } from 'typeorm';
import { z } from 'zod';
const blacklistRoutes = Router();
const blocklistRoutes = Router();
export const blacklistAdd = z.object({
export const blocklistAdd = z.object({
tmdbId: z.coerce.number(),
mediaType: z.nativeEnum(MediaType),
title: z.coerce.string().optional(),
user: z.coerce.number(),
});
const blacklistGet = z.object({
const blocklistGet = z.object({
take: z.coerce.number().int().positive().default(25),
skip: z.coerce.number().int().nonnegative().default(0),
search: z.string().optional(),
filter: z.enum(['all', 'manual', 'blacklistedTags']).optional(),
filter: z.enum(['all', 'manual', 'blocklistedTags']).optional(),
});
blacklistRoutes.get(
blocklistRoutes.get(
'/',
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
isAuthenticated([Permission.MANAGE_BLOCKLIST, Permission.VIEW_BLOCKLIST], {
type: 'or',
}),
async (req, res, next) => {
const { take, skip, search, filter } = blacklistGet.parse(req.query);
const { take, skip, search, filter } = blocklistGet.parse(req.query);
try {
let query = getRepository(Blacklist)
.createQueryBuilder('blacklist')
.leftJoinAndSelect('blacklist.user', 'user')
let query = getRepository(Blocklist)
.createQueryBuilder('blocklist')
.leftJoinAndSelect('blocklist.user', 'user')
.where('1 = 1'); // Allow use of andWhere later
switch (filter) {
case 'manual':
query = query.andWhere('blacklist.blacklistedTags IS NULL');
query = query.andWhere('blocklist.blocklistedTags IS NULL');
break;
case 'blacklistedTags':
query = query.andWhere('blacklist.blacklistedTags IS NOT NULL');
case 'blocklistedTags':
query = query.andWhere('blocklist.blocklistedTags IS NOT NULL');
break;
}
if (search) {
query = query.andWhere('blacklist.title like :title', {
query = query.andWhere('blocklist.title like :title', {
title: `%${search}%`,
});
}
const [blacklistedItems, itemsCount] = await query
.orderBy('blacklist.createdAt', 'DESC')
const [blocklistedItems, itemsCount] = await query
.orderBy('blocklist.createdAt', 'DESC')
.take(take)
.skip(skip)
.getManyAndCount();
@@ -68,35 +68,35 @@ blacklistRoutes.get(
results: itemsCount,
page: Math.ceil(skip / take) + 1,
},
results: blacklistedItems,
} as BlacklistResultsResponse);
results: blocklistedItems,
} as BlocklistResultsResponse);
} catch (error) {
logger.error('Something went wrong while retrieving blacklisted items', {
label: 'Blacklist',
logger.error('Something went wrong while retrieving blocklisted items', {
label: 'Blocklist',
errorMessage: error.message,
});
return next({
status: 500,
message: 'Unable to retrieve blacklisted items.',
message: 'Unable to retrieve blocklisted items.',
});
}
}
);
blacklistRoutes.get(
blocklistRoutes.get(
'/:id',
isAuthenticated([Permission.MANAGE_BLACKLIST], {
isAuthenticated([Permission.MANAGE_BLOCKLIST], {
type: 'or',
}),
async (req, res, next) => {
try {
const blacklisteRepository = getRepository(Blacklist);
const blocklisteRepository = getRepository(Blocklist);
const blacklistItem = await blacklisteRepository.findOneOrFail({
const blocklistItem = await blocklisteRepository.findOneOrFail({
where: { tmdbId: Number(req.params.id) },
});
return res.status(200).send(blacklistItem);
return res.status(200).send(blocklistItem);
} catch (e) {
if (e instanceof EntityNotFoundError) {
return next({
@@ -109,17 +109,17 @@ blacklistRoutes.get(
}
);
blacklistRoutes.post(
blocklistRoutes.post(
'/',
isAuthenticated([Permission.MANAGE_BLACKLIST], {
isAuthenticated([Permission.MANAGE_BLOCKLIST], {
type: 'or',
}),
async (req, res, next) => {
try {
const values = blacklistAdd.parse(req.body);
const values = blocklistAdd.parse(req.body);
await Blacklist.addToBlacklist({
blacklistRequest: values,
await Blocklist.addToBlocklist({
blocklistRequest: values,
});
return res.status(201).send();
@@ -131,12 +131,12 @@ blacklistRoutes.post(
if (error instanceof QueryFailedError) {
switch (error.driverError.errno) {
case 19:
return next({ status: 412, message: 'Item already blacklisted' });
return next({ status: 412, message: 'Item already blocklisted' });
default:
logger.warn('Something wrong with data blacklist', {
logger.warn('Something wrong with data blocklist', {
tmdbId: req.body.tmdbId,
mediaType: req.body.mediaType,
label: 'Blacklist',
label: 'Blocklist',
});
return next({ status: 409, message: 'Something wrong' });
}
@@ -147,20 +147,20 @@ blacklistRoutes.post(
}
);
blacklistRoutes.delete(
blocklistRoutes.delete(
'/:id',
isAuthenticated([Permission.MANAGE_BLACKLIST], {
isAuthenticated([Permission.MANAGE_BLOCKLIST], {
type: 'or',
}),
async (req, res, next) => {
try {
const blacklisteRepository = getRepository(Blacklist);
const blocklisteRepository = getRepository(Blocklist);
const blacklistItem = await blacklisteRepository.findOneOrFail({
const blocklistItem = await blocklisteRepository.findOneOrFail({
where: { tmdbId: Number(req.params.id) },
});
await blacklisteRepository.remove(blacklistItem);
await blocklisteRepository.remove(blocklistItem);
const mediaRepository = getRepository(Media);
@@ -183,4 +183,4 @@ blacklistRoutes.delete(
}
);
export default blacklistRoutes;
export default blocklistRoutes;

View File

@@ -12,11 +12,12 @@ import { Permission } from '@server/lib/permissions';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { checkUser, isAuthenticated } from '@server/middleware/auth';
import deprecatedRoute from '@server/middleware/deprecation';
import { mapProductionCompany } from '@server/models/Movie';
import { mapNetwork } from '@server/models/Tv';
import { mapWatchProviderDetails } from '@server/models/common';
import overrideRuleRoutes from '@server/routes/overrideRule';
import settingsRoutes from '@server/routes/settings';
import routingRuleRoutes from '@server/routes/settings/routingRule';
import watchlistRoutes from '@server/routes/watchlist';
import {
appDataPath,
@@ -28,7 +29,7 @@ import restartFlag from '@server/utils/restartFlag';
import { isPerson } from '@server/utils/typeHelpers';
import { Router } from 'express';
import authRoutes from './auth';
import blacklistRoutes from './blacklist';
import blocklistRoutes from './blocklist';
import collectionRoutes from './collection';
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
import issueRoutes from './issue';
@@ -151,7 +152,17 @@ router.use('/search', isAuthenticated(), searchRoutes);
router.use('/discover', isAuthenticated(), discoverRoutes);
router.use('/request', isAuthenticated(), requestRoutes);
router.use('/watchlist', isAuthenticated(), watchlistRoutes);
router.use('/blacklist', isAuthenticated(), blacklistRoutes);
router.use('/blocklist', isAuthenticated(), blocklistRoutes);
router.use(
'/blacklist',
isAuthenticated(),
deprecatedRoute({
oldPath: '/api/v1/blacklist',
newPath: '/api/v1/blocklist',
sunsetDate: '2026-06-01',
}),
blocklistRoutes
);
router.use('/movie', isAuthenticated(), movieRoutes);
router.use('/tv', isAuthenticated(), tvRoutes);
router.use('/media', isAuthenticated(), mediaRoutes);
@@ -162,9 +173,9 @@ router.use('/issue', isAuthenticated(), issueRoutes);
router.use('/issueComment', isAuthenticated(), issueCommentRoutes);
router.use('/auth', authRoutes);
router.use(
'/overrideRule',
'/routingRule',
isAuthenticated(Permission.ADMIN),
overrideRuleRoutes
routingRuleRoutes
);
router.get('/regions', isAuthenticated(), async (req, res, next) => {

View File

@@ -1,136 +0,0 @@
import { getRepository } from '@server/datasource';
import OverrideRule from '@server/entity/OverrideRule';
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
import { Permission } from '@server/lib/permissions';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
const overrideRuleRoutes = Router();
overrideRuleRoutes.get(
'/',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rules = await overrideRuleRepository.find({});
return res.status(200).json(rules as OverrideRuleResultsResponse);
} catch (e) {
next({ status: 404, message: e.message });
}
}
);
overrideRuleRoutes.post<
Record<string, string>,
OverrideRule,
{
users?: string;
genre?: string;
language?: string;
keywords?: string;
profileId?: number;
rootFolder?: string;
tags?: string;
radarrServiceId?: number;
sonarrServiceId?: number;
}
>('/', isAuthenticated(Permission.ADMIN), async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rule = new OverrideRule({
users: req.body.users,
genre: req.body.genre,
language: req.body.language,
keywords: req.body.keywords,
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
tags: req.body.tags,
radarrServiceId: req.body.radarrServiceId,
sonarrServiceId: req.body.sonarrServiceId,
});
const newRule = await overrideRuleRepository.save(rule);
return res.status(200).json(newRule);
} catch (e) {
next({ status: 404, message: e.message });
}
});
overrideRuleRoutes.put<
{ ruleId: string },
OverrideRule,
{
users?: string;
genre?: string;
language?: string;
keywords?: string;
profileId?: number;
rootFolder?: string;
tags?: string;
radarrServiceId?: number;
sonarrServiceId?: number;
}
>('/:ruleId', isAuthenticated(Permission.ADMIN), async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rule = await overrideRuleRepository.findOne({
where: {
id: Number(req.params.ruleId),
},
});
if (!rule) {
return next({ status: 404, message: 'Override Rule not found.' });
}
rule.users = req.body.users;
rule.genre = req.body.genre;
rule.language = req.body.language;
rule.keywords = req.body.keywords;
rule.profileId = req.body.profileId;
rule.rootFolder = req.body.rootFolder;
rule.tags = req.body.tags;
rule.radarrServiceId = req.body.radarrServiceId;
rule.sonarrServiceId = req.body.sonarrServiceId;
const newRule = await overrideRuleRepository.save(rule);
return res.status(200).json(newRule);
} catch (e) {
next({ status: 404, message: e.message });
}
});
overrideRuleRoutes.delete<{ ruleId: string }, OverrideRule>(
'/:ruleId',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
const overrideRuleRepository = getRepository(OverrideRule);
try {
const rule = await overrideRuleRepository.findOne({
where: {
id: Number(req.params.ruleId),
},
});
if (!rule) {
return next({ status: 404, message: 'Override Rule not found.' });
}
await overrideRuleRepository.remove(rule);
return res.status(200).json(rule);
} catch (e) {
next({ status: 404, message: e.message });
}
}
);
export default overrideRuleRoutes;

View File

@@ -8,7 +8,7 @@ import {
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import {
BlacklistedMediaError,
BlocklistedMediaError,
DuplicateMediaRequestError,
MediaRequest,
NoSeasonsAvailableError,
@@ -326,7 +326,7 @@ requestRoutes.post<never, MediaRequest, MediaRequestBody>(
return next({ status: 409, message: error.message });
case NoSeasonsAvailableError:
return next({ status: 202, message: error.message });
case BlacklistedMediaError:
case BlocklistedMediaError:
return next({ status: 403, message: error.message });
default:
return next({ status: 500, message: error.message });

View File

@@ -1,4 +1,6 @@
import RadarrAPI from '@server/api/servarr/radarr';
import { getRepository } from '@server/datasource';
import RoutingRule from '@server/entity/RoutingRule';
import type { RadarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
@@ -136,6 +138,7 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => {
radarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => {
const settings = getSettings();
const routingRuleRepository = getRepository(RoutingRule);
const radarrIndex = settings.radarr.findIndex(
(r) => r.id === Number(req.params.id)
@@ -145,6 +148,19 @@ radarrRoutes.delete<{ id: string }>('/:id', async (req, res, next) => {
return next({ status: '404', message: 'Settings instance not found' });
}
const instanceId = Number(req.params.id);
const rulesToDelete = await routingRuleRepository.find({
where: {
serviceType: 'radarr',
targetServiceId: instanceId,
},
});
if (rulesToDelete.length > 0) {
await routingRuleRepository.remove(rulesToDelete);
}
const removed = settings.radarr.splice(radarrIndex, 1);
await settings.save();

View File

@@ -0,0 +1,370 @@
import { getRepository } from '@server/datasource';
import RoutingRule from '@server/entity/RoutingRule';
import { Permission } from '@server/lib/permissions';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
import { In, Not } from 'typeorm';
const routingRuleRoutes = Router();
type ServiceType = 'radarr' | 'sonarr';
function resolveTargetService(
serviceType: ServiceType,
targetServiceId: number
): RadarrSettings | SonarrSettings | undefined {
const settings = getSettings();
const services = serviceType === 'radarr' ? settings.radarr : settings.sonarr;
return services.find((s) => s.id === targetServiceId);
}
function hasAnyCondition(body: Record<string, unknown>): boolean {
return !!(body.users || body.genres || body.languages || body.keywords);
}
function parseActiveProfileId(
raw: string | number | null | undefined
): number | null {
if (raw === '' || raw == null) return null;
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
routingRuleRoutes.get(
'/',
isAuthenticated(Permission.ADMIN),
async (_req, res, next) => {
const routingRuleRepository = getRepository(RoutingRule);
try {
const rules = await routingRuleRepository.find({
order: { isFallback: 'ASC', priority: 'DESC' },
});
return res.status(200).json(rules);
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
routingRuleRoutes.post(
'/',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
const routingRuleRepository = getRepository(RoutingRule);
try {
const serviceType = req.body.serviceType as ServiceType;
const targetServiceId = Number(req.body.targetServiceId);
if (!serviceType || !['radarr', 'sonarr'].includes(serviceType)) {
return next({ status: 400, message: 'Invalid serviceType.' });
}
if (!Number.isFinite(targetServiceId) || targetServiceId < 0) {
return next({ status: 400, message: 'Invalid targetServiceId.' });
}
const target = resolveTargetService(serviceType, targetServiceId);
if (!target) {
return next({ status: 400, message: 'Target instance not found.' });
}
const derivedIs4k = !!target.is4k;
const isFallback = !!req.body.isFallback;
if (isFallback) {
const existing = await routingRuleRepository.findOne({
where: { serviceType, is4k: derivedIs4k, isFallback: true },
});
if (existing) {
return next({
status: 409,
message: 'Fallback already exists for this serviceType/is4k.',
});
}
if (!target.isDefault) {
return next({
status: 400,
message: 'Fallback rules must target a default instance.',
});
}
}
if (!isFallback && !hasAnyCondition(req.body)) {
return next({
status: 400,
message: 'Non-fallback rules must have at least one condition.',
});
}
const activeProfileId = parseActiveProfileId(req.body.activeProfileId);
if (isFallback) {
if (!req.body.rootFolder) {
return next({
status: 400,
message: 'Fallback requires rootFolder.',
});
}
if (activeProfileId == null) {
return next({
status: 400,
message: 'Fallback requires activeProfileId.',
});
}
if (serviceType === 'radarr' && !req.body.minimumAvailability) {
return next({
status: 400,
message: 'Fallback requires minimumAvailability for radarr.',
});
}
}
let priority = 0;
if (!isFallback) {
const highestRule = await routingRuleRepository.findOne({
where: { serviceType, is4k: derivedIs4k, isFallback: false },
order: { priority: 'DESC' },
});
priority = (highestRule?.priority ?? 0) + 10;
}
const rule = new RoutingRule({
name: req.body.name,
serviceType,
targetServiceId,
is4k: derivedIs4k,
isFallback,
priority,
users: isFallback ? null : req.body.users,
genres: isFallback ? null : req.body.genres,
languages: isFallback ? null : req.body.languages,
keywords: isFallback ? null : req.body.keywords,
activeProfileId: activeProfileId ?? undefined,
rootFolder: req.body.rootFolder,
seriesType: req.body.seriesType,
tags: req.body.tags,
minimumAvailability: req.body.minimumAvailability ?? null,
});
const newRule = await routingRuleRepository.save(rule);
return res.status(201).json(newRule);
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
routingRuleRoutes.put<{ ruleId: string }>(
'/:ruleId',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
const routingRuleRepository = getRepository(RoutingRule);
try {
const rule = await routingRuleRepository.findOne({
where: { id: Number(req.params.ruleId) },
});
if (!rule) {
return next({ status: 404, message: 'Routing rule not found.' });
}
const nextServiceType = (req.body.serviceType ??
rule.serviceType) as ServiceType;
const nextTargetServiceId = Number(
req.body.targetServiceId ?? rule.targetServiceId
);
const target = resolveTargetService(nextServiceType, nextTargetServiceId);
if (!target) {
return next({ status: 400, message: 'Target instance not found.' });
}
const derivedIs4k = !!target.is4k;
const derivedIsDefault = !!target.isDefault;
const nextIsFallback = !!(req.body.isFallback ?? rule.isFallback);
if (nextIsFallback) {
const existing = await routingRuleRepository.findOne({
where: {
serviceType: nextServiceType,
is4k: derivedIs4k,
isFallback: true,
id: Not(rule.id),
},
});
if (existing) {
return next({
status: 409,
message: 'Fallback already exists for this serviceType/is4k.',
});
}
}
const mergedForConditionCheck = { ...rule, ...req.body };
if (!nextIsFallback && !hasAnyCondition(mergedForConditionCheck)) {
return next({
status: 400,
message: 'Non-fallback rules must have at least one condition.',
});
}
if (nextIsFallback && !derivedIsDefault) {
return next({
status: 400,
message: 'Fallback rules must target a default instance.',
});
}
const nextActiveProfileId = parseActiveProfileId(
req.body.activeProfileId ?? rule.activeProfileId
);
const nextRootFolder = (req.body.rootFolder ?? rule.rootFolder) as
| string
| undefined;
const nextMinimumAvailability =
nextServiceType === 'radarr'
? (req.body.minimumAvailability ?? rule.minimumAvailability)
: null;
if (nextIsFallback) {
if (!nextRootFolder) {
return next({
status: 400,
message: 'Fallback requires rootFolder.',
});
}
if (nextActiveProfileId == null) {
return next({
status: 400,
message: 'Fallback requires activeProfileId.',
});
}
if (nextServiceType === 'radarr' && !nextMinimumAvailability) {
return next({
status: 400,
message: 'Fallback requires minimumAvailability for radarr.',
});
}
}
if (nextIsFallback) {
rule.priority = 0;
} else if (typeof req.body.priority === 'number') {
rule.priority = req.body.priority;
} else {
const groupChanged =
rule.serviceType !== nextServiceType ||
rule.is4k !== derivedIs4k ||
rule.isFallback;
if (groupChanged) {
const highestRule = await routingRuleRepository.findOne({
where: {
serviceType: nextServiceType,
is4k: derivedIs4k,
isFallback: false,
},
order: { priority: 'DESC' },
});
rule.priority = (highestRule?.priority ?? 0) + 10;
}
}
rule.name = req.body.name ?? rule.name;
rule.serviceType = nextServiceType;
rule.targetServiceId = nextTargetServiceId;
rule.is4k = derivedIs4k;
rule.isFallback = nextIsFallback;
rule.users = nextIsFallback ? null : req.body.users;
rule.genres = nextIsFallback ? null : req.body.genres;
rule.languages = nextIsFallback ? null : req.body.languages;
rule.keywords = nextIsFallback ? null : req.body.keywords;
rule.activeProfileId = nextActiveProfileId ?? undefined;
rule.rootFolder = nextRootFolder;
rule.minimumAvailability = nextMinimumAvailability;
rule.tags = req.body.tags;
const updatedRule = await routingRuleRepository.save(rule);
return res.status(200).json(updatedRule);
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
routingRuleRoutes.delete<{ ruleId: string }>(
'/:ruleId',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
const routingRuleRepository = getRepository(RoutingRule);
try {
const rule = await routingRuleRepository.findOne({
where: { id: Number(req.params.ruleId) },
});
if (!rule) {
return next({ status: 404, message: 'Routing rule not found.' });
}
await routingRuleRepository.remove(rule);
return res.status(200).json(rule);
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
routingRuleRoutes.post(
'/reorder',
isAuthenticated(Permission.ADMIN),
async (req, res, next) => {
const routingRuleRepository = getRepository(RoutingRule);
try {
const { ruleIds } = req.body as { ruleIds: number[] };
const MAX_RULE_IDS = 1000;
if (!Array.isArray(ruleIds)) {
return next({ status: 400, message: 'ruleIds must be an array.' });
}
if (ruleIds.length > MAX_RULE_IDS) {
return next({
status: 400,
message: `Too many ruleIds provided. Maximum allowed is ${MAX_RULE_IDS}.`,
});
}
const rules = await routingRuleRepository.findBy({ id: In(ruleIds) });
const fallbackIds = new Set(
rules.filter((r) => r.isFallback).map((r) => r.id)
);
const orderedIds = ruleIds.filter((id) => !fallbackIds.has(id));
for (let i = 0; i < orderedIds.length; i++) {
await routingRuleRepository.update(orderedIds[i], {
priority: (orderedIds.length - i) * 10,
});
}
const refreshed = await routingRuleRepository.find({
order: { isFallback: 'ASC', priority: 'DESC' },
});
return res.status(200).json(refreshed);
} catch (e) {
next({ status: 500, message: e.message });
}
}
);
export default routingRuleRoutes;

View File

@@ -1,4 +1,6 @@
import SonarrAPI from '@server/api/servarr/sonarr';
import { getRepository } from '@server/datasource';
import RoutingRule from '@server/entity/RoutingRule';
import type { SonarrSettings } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
@@ -108,6 +110,7 @@ sonarrRoutes.put<{ id: string }>('/:id', async (req, res) => {
sonarrRoutes.delete<{ id: string }>('/:id', async (req, res) => {
const settings = getSettings();
const routingRuleRepository = getRepository(RoutingRule);
const sonarrIndex = settings.sonarr.findIndex(
(r) => r.id === Number(req.params.id)
@@ -119,6 +122,19 @@ sonarrRoutes.delete<{ id: string }>('/:id', async (req, res) => {
.json({ status: '404', message: 'Settings instance not found' });
}
const instanceId = Number(req.params.id);
const rulesToDelete = await routingRuleRepository.find({
where: {
serviceType: 'sonarr',
targetServiceId: instanceId,
},
});
if (rulesToDelete.length > 0) {
await routingRuleRepository.remove(rulesToDelete);
}
const removed = settings.sonarr.splice(sonarrIndex, 1);
await settings.save();

View File

@@ -1,4 +1,4 @@
import BlacklistedTagsBadge from '@app/components/BlacklistedTagsBadge';
import BlocklistedTagsBadge from '@app/components/BlocklistedTagsBadge';
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import CachedImage from '@app/components/Common/CachedImage';
@@ -20,9 +20,9 @@ import {
TrashIcon,
} from '@heroicons/react/24/solid';
import type {
BlacklistItem,
BlacklistResultsResponse,
} from '@server/interfaces/api/blacklistInterfaces';
BlocklistItem,
BlocklistResultsResponse,
} from '@server/interfaces/api/blocklistInterfaces';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
import axios from 'axios';
@@ -35,31 +35,31 @@ import { FormattedRelativeTime, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages('components.Blacklist', {
blacklistsettings: 'Blacklist Settings',
blacklistSettingsDescription: 'Manage blacklisted media.',
const messages = defineMessages('components.Blocklist', {
blocklistsettings: 'Blocklist Settings',
blocklistSettingsDescription: 'Manage blocklisted media.',
mediaName: 'Name',
mediaType: 'Type',
mediaTmdbId: 'tmdb Id',
blacklistdate: 'date',
blacklistedby: '{date} by {user}',
blacklistNotFoundError: '<strong>{title}</strong> is not blacklisted.',
blocklistdate: 'date',
blocklistedby: '{date} by {user}',
blocklistNotFoundError: '<strong>{title}</strong> is not blocklisted.',
filterManual: 'Manual',
filterBlacklistedTags: 'Blacklisted Tags',
showAllBlacklisted: 'Show All Blacklisted Media',
filterBlocklistedTags: 'Blocklisted Tags',
showAllBlocklisted: 'Show All Blocklisted Media',
});
enum Filter {
ALL = 'all',
MANUAL = 'manual',
BLACKLISTEDTAGS = 'blacklistedTags',
BLOCKLISTEDTAGS = 'blocklistedTags',
}
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
return (movie as MovieDetails).title !== undefined;
};
const Blacklist = () => {
const Blocklist = () => {
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
const [searchFilter, debouncedSearchFilter, setSearchFilter] =
useDebouncedState('');
@@ -75,8 +75,8 @@ const Blacklist = () => {
data,
error,
mutate: revalidate,
} = useSWR<BlacklistResultsResponse>(
`/api/v1/blacklist/?take=${currentPageSize}&skip=${
} = useSWR<BlocklistResultsResponse>(
`/api/v1/blocklist/?take=${currentPageSize}&skip=${
pageIndex * currentPageSize
}&filter=${currentFilter}${
debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''
@@ -107,9 +107,9 @@ const Blacklist = () => {
return (
<>
<PageTitle title={[intl.formatMessage(globalMessages.blacklist)]} />
<PageTitle title={[intl.formatMessage(globalMessages.blocklist)]} />
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
<Header>{intl.formatMessage(globalMessages.blacklist)}</Header>
<Header>{intl.formatMessage(globalMessages.blocklist)}</Header>
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
@@ -137,8 +137,8 @@ const Blacklist = () => {
<option value="manual">
{intl.formatMessage(messages.filterManual)}
</option>
<option value="blacklistedTags">
{intl.formatMessage(messages.filterBlacklistedTags)}
<option value="blocklistedTags">
{intl.formatMessage(messages.filterBlocklistedTags)}
</option>
</select>
</div>
@@ -170,16 +170,16 @@ const Blacklist = () => {
buttonType="primary"
onClick={() => setCurrentFilter(Filter.ALL)}
>
{intl.formatMessage(messages.showAllBlacklisted)}
{intl.formatMessage(messages.showAllBlocklisted)}
</Button>
</div>
)}
</div>
) : (
data.results.map((item: BlacklistItem) => {
data.results.map((item: BlocklistItem) => {
return (
<div className="py-2" key={`request-list-${item.tmdbId}`}>
<BlacklistedItem item={item} revalidateList={revalidate} />
<BlocklistedItem item={item} revalidateList={revalidate} />
</div>
);
})
@@ -260,14 +260,14 @@ const Blacklist = () => {
);
};
export default Blacklist;
export default Blocklist;
interface BlacklistedItemProps {
item: BlacklistItem;
interface BlocklistedItemProps {
item: BlocklistItem;
revalidateList: () => void;
}
const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
const BlocklistedItem = ({ item, revalidateList }: BlocklistedItemProps) => {
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const { addToast } = useToasts();
const { ref, inView } = useInView({
@@ -293,15 +293,15 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
);
}
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
const removeFromBlocklist = async (tmdbId: number, title?: string) => {
setIsUpdating(true);
try {
await axios.delete(`/api/v1/blacklist/${tmdbId}`);
await axios.delete(`/api/v1/blocklist/${tmdbId}`);
addToast(
<span>
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
{intl.formatMessage(globalMessages.removeFromBlocklistSuccess, {
title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
@@ -309,7 +309,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
{ appearance: 'success', autoDismiss: true }
);
} catch {
addToast(intl.formatMessage(globalMessages.blacklistError), {
addToast(intl.formatMessage(globalMessages.blocklistError), {
appearance: 'error',
autoDismiss: true,
});
@@ -389,17 +389,17 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
<div className="card-field">
<span className="card-field-name">Status</span>
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.blacklisted)}
{intl.formatMessage(globalMessages.blocklisted)}
</Badge>
</div>
{item.createdAt && (
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(globalMessages.blacklisted)}
{intl.formatMessage(globalMessages.blocklisted)}
</span>
<span className="flex truncate text-sm text-gray-300">
{intl.formatMessage(messages.blacklistedby, {
{intl.formatMessage(messages.blocklistedby, {
date: (
<FormattedRelativeTime
value={Math.floor(
@@ -426,9 +426,9 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
</span>
</span>
</Link>
) : item.blacklistedTags ? (
) : item.blocklistedTags ? (
<span className="ml-1">
<BlacklistedTagsBadge data={item} />
<BlocklistedTagsBadge data={item} />
</span>
) : (
<span className="ml-1 truncate text-sm font-semibold">
@@ -457,10 +457,10 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
</div>
</div>
<div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
{hasPermission(Permission.MANAGE_BLACKLIST) && (
{hasPermission(Permission.MANAGE_BLOCKLIST) && (
<ConfirmButton
onClick={() =>
removeFromBlacklist(
removeFromBlocklist(
item.tmdbId,
title && (isMovie(title) ? title.title : title.name)
)
@@ -474,7 +474,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
>
<TrashIcon />
<span>
{intl.formatMessage(globalMessages.removefromBlacklist)}
{intl.formatMessage(globalMessages.removefromBlocklist)}
</span>
</ConfirmButton>
)}

View File

@@ -1,4 +1,4 @@
import BlacklistedTagsBadge from '@app/components/BlacklistedTagsBadge';
import BlocklistedTagsBadge from '@app/components/BlocklistedTagsBadge';
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
@@ -7,7 +7,7 @@ import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { CalendarIcon, TrashIcon, UserIcon } from '@heroicons/react/24/solid';
import type { Blacklist } from '@server/entity/Blacklist';
import type { Blocklist } from '@server/entity/Blocklist';
import axios from 'axios';
import Link from 'next/link';
import { useState } from 'react';
@@ -15,37 +15,37 @@ import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages('component.BlacklistBlock', {
blacklistedby: 'Blacklisted By',
blacklistdate: 'Blacklisted date',
const messages = defineMessages('component.BlocklistBlock', {
blocklistedby: 'Blocklisted By',
blocklistdate: 'Blocklisted date',
});
interface BlacklistBlockProps {
interface BlocklistBlockProps {
tmdbId: number;
onUpdate?: () => void;
onDelete?: () => void;
}
const BlacklistBlock = ({
const BlocklistBlock = ({
tmdbId,
onUpdate,
onDelete,
}: BlacklistBlockProps) => {
}: BlocklistBlockProps) => {
const { user } = useUser();
const intl = useIntl();
const [isUpdating, setIsUpdating] = useState(false);
const { addToast } = useToasts();
const { data } = useSWR<Blacklist>(`/api/v1/blacklist/${tmdbId}`);
const { data } = useSWR<Blocklist>(`/api/v1/blocklist/${tmdbId}`);
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
const removeFromBlocklist = async (tmdbId: number, title?: string) => {
setIsUpdating(true);
try {
await axios.delete('/api/v1/blacklist/' + tmdbId);
await axios.delete('/api/v1/blocklist/' + tmdbId);
addToast(
<span>
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
{intl.formatMessage(globalMessages.removeFromBlocklistSuccess, {
title,
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
})}
@@ -53,7 +53,7 @@ const BlacklistBlock = ({
{ appearance: 'success', autoDismiss: true }
);
} catch {
addToast(intl.formatMessage(globalMessages.blacklistError), {
addToast(intl.formatMessage(globalMessages.blocklistError), {
appearance: 'error',
autoDismiss: true,
});
@@ -80,7 +80,7 @@ const BlacklistBlock = ({
<div className="white mb-1 flex flex-nowrap">
{data.user ? (
<>
<Tooltip content={intl.formatMessage(messages.blacklistedby)}>
<Tooltip content={intl.formatMessage(messages.blocklistedby)}>
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
</Tooltip>
<span className="w-40 truncate md:w-auto">
@@ -97,23 +97,23 @@ const BlacklistBlock = ({
</Link>
</span>
</>
) : data.blacklistedTags ? (
) : data.blocklistedTags ? (
<>
<span className="w-40 truncate md:w-auto">
{intl.formatMessage(messages.blacklistedby)}:&nbsp;
{intl.formatMessage(messages.blocklistedby)}:&nbsp;
</span>
<BlacklistedTagsBadge data={data} />
<BlocklistedTagsBadge data={data} />
</>
) : null}
</div>
</div>
<div className="ml-2 flex flex-shrink-0 flex-wrap">
<Tooltip
content={intl.formatMessage(globalMessages.removefromBlacklist)}
content={intl.formatMessage(globalMessages.removefromBlocklist)}
>
<Button
buttonType="danger"
onClick={() => removeFromBlacklist(data.tmdbId, data.title)}
onClick={() => removeFromBlocklist(data.tmdbId, data.title)}
disabled={isUpdating}
>
<TrashIcon className="icon-sm" />
@@ -125,12 +125,12 @@ const BlacklistBlock = ({
<div className="sm:flex">
<div className="mr-6 flex items-center text-sm leading-5">
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.blacklisted)}
{intl.formatMessage(globalMessages.blocklisted)}
</Badge>
</div>
</div>
<div className="mt-2 flex items-center text-sm leading-5 sm:mt-0">
<Tooltip content={intl.formatMessage(messages.blacklistdate)}>
<Tooltip content={intl.formatMessage(messages.blocklistdate)}>
<CalendarIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
</Tooltip>
<span>
@@ -146,4 +146,4 @@ const BlacklistBlock = ({
);
};
export default BlacklistBlock;
export default BlocklistBlock;

Some files were not shown because too many files have changed in this diff Show More