Compare commits

..

21 Commits

Author SHA1 Message Date
gauthier-th
637712e4fc feat: add script for SQLite to PostgreSQL migration 2026-02-18 14:50:40 +01:00
blassley
5e64d49c32 docs(unraid): improve unraid migration guide (#2470) 2026-02-17 05:12:47 +05:00
fallenbagel
c6bcfe0ae4 perf: add missing indexes on all foreign key columns (#2461) 2026-02-17 00:36:55 +08:00
Jan Kleine
6076878f76 docs(notifications): revise web push notification docs [skip ci] (#2451)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-16 17:19:43 +05:00
Sandesh Koirala
8f0c904928 feat(helm): use an existing PVC as config volume (#2447) 2026-02-16 09:09:11 +01: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
fallenbagel
1ed86c14c0 fix(media-request-subscriber): prevent mediald nullification from cascade saves (#2356) 2026-02-13 15:02:22 +05:00
fallenbagel
91261f6a61 fix(settings): DNS cache UI consistency, validation, and conditional rendering (#2382) 2026-02-13 00:16:10 +01:00
Gauthier
3dea58eead fix(overriderules): display the users of an override rule (#2410) 2026-02-12 23:20:16 +01:00
142 changed files with 2072 additions and 1593 deletions

4
.github/cliff.toml vendored
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
FROM node:22.22.0-alpine3.22@sha256:7aa86fa052f6e4b101557ccb56717cb4311be1334381f526fe013418fe157384 AS base FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284 AS base
ARG SOURCE_DATE_EPOCH ARG SOURCE_DATE_EPOCH
ARG TARGETPLATFORM ARG TARGETPLATFORM
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64} ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
@@ -33,7 +33,7 @@ RUN pnpm build
RUN rm -rf .next/cache RUN rm -rf .next/cache
FROM node:22.22.0-alpine3.22@sha256:7aa86fa052f6e4b101557ccb56717cb4311be1334381f526fe013418fe157384 FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
ARG SOURCE_DATE_EPOCH ARG SOURCE_DATE_EPOCH
ARG COMMIT_TAG ARG COMMIT_TAG
ENV NODE_ENV=production ENV NODE_ENV=production

View File

@@ -1,4 +1,4 @@
FROM node:22.22.0-alpine3.22@sha256:7aa86fa052f6e4b101557ccb56717cb4311be1334381f526fe013418fe157384 FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"

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> <p align="center">
<img src="./public/logo_full.svg" alt="Seerr" style="margin: 20px 0;">
<h1 align="center" style="font-size: 4em;">🚧 Seerr</h1> </p>
<p align="center"> <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/release.yml/badge.svg" alt="Seerr Release" />
<img src="https://github.com/seerr-team/seerr/actions/workflows/ci.yml/badge.svg" alt="Seerr CI"> <img src="https://github.com/seerr-team/seerr/actions/workflows/ci.yml/badge.svg" alt="Seerr CI">
@@ -26,37 +26,25 @@
- Granular permission system. - Granular permission system.
- Support for various notification agents. - Support for various notification agents.
- Mobile-friendly design, for when you need to approve requests on the go! - 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. With more features on the way! Check out our [issue tracker](/../../issues) to see the features which have already been requested.
## Getting Started ## 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/ 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 ## 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 ## Support

View File

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

View File

@@ -1,6 +1,6 @@
# seerr-chart # 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.1.0](https://img.shields.io/badge/Version-3.1.0-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 Seerr helm chart for Kubernetes
@@ -44,9 +44,10 @@ If `replicaCount` value was used - remove it. Helm update should work fine after
| Key | Type | Default | Description | | Key | Type | Default | Description |
|-----|------|---------|-------------| |-----|------|---------|-------------|
| affinity | object | `{}` | | | affinity | object | `{}` | |
| config | object | `{"persistence":{"accessModes":["ReadWriteOnce"],"annotations":{},"name":"","size":"5Gi","volumeName":""}}` | Creating PVC to store configuration | | config | object | `{"persistence":{"accessModes":["ReadWriteOnce"],"annotations":{},"existingClaim":"","name":"","size":"5Gi","volumeName":""}}` | Creating PVC to store configuration |
| config.persistence.accessModes | list | `["ReadWriteOnce"]` | Access modes of persistent disk | | config.persistence.accessModes | list | `["ReadWriteOnce"]` | Access modes of persistent disk |
| config.persistence.annotations | object | `{}` | Annotations for PVCs | | config.persistence.annotations | object | `{}` | Annotations for PVCs |
| config.persistence.existingClaim | string | `""` | Specify an existing `PersistentVolumeClaim` to use. If this value is provided, the default PVC will not be created |
| config.persistence.name | string | `""` | Config name | | config.persistence.name | string | `""` | Config name |
| config.persistence.size | string | `"5Gi"` | Size of persistent disk | | config.persistence.size | string | `"5Gi"` | Size of persistent disk |
| config.persistence.volumeName | string | `""` | Name of the permanent volume to reference in the claim. Can be used to bind to existing volumes. | | config.persistence.volumeName | string | `""` | Name of the permanent volume to reference in the claim. Can be used to bind to existing volumes. |

View File

@@ -1,3 +1,4 @@
{{- if not .Values.config.persistence.existingClaim -}}
apiVersion: v1 apiVersion: v1
kind: PersistentVolumeClaim kind: PersistentVolumeClaim
metadata: metadata:
@@ -22,3 +23,4 @@ spec:
resources: resources:
requests: requests:
storage: "{{ .Values.config.persistence.size }}" storage: "{{ .Values.config.persistence.size }}"
{{- end -}}

View File

@@ -103,7 +103,7 @@ spec:
volumes: volumes:
- name: config - name: config
persistentVolumeClaim: persistentVolumeClaim:
claimName: {{ include "seerr.configPersistenceName" . }} claimName: {{ if .Values.config.persistence.existingClaim }}{{ .Values.config.persistence.existingClaim }}{{- else }}{{ include "seerr.configPersistenceName" . }}{{- end }}
{{- with .Values.volumes }} {{- with .Values.volumes }}
{{- toYaml . | nindent 8 }} {{- toYaml . | nindent 8 }}
{{- end }} {{- end }}

View File

@@ -86,6 +86,8 @@ config:
# -- Name of the permanent volume to reference in the claim. # -- Name of the permanent volume to reference in the claim.
# Can be used to bind to existing volumes. # Can be used to bind to existing volumes.
volumeName: '' volumeName: ''
# -- Specify an existing `PersistentVolumeClaim` to use. If this value is provided, the default PVC will not be created
existingClaim: ''
ingress: ingress:
enabled: false enabled: false

View File

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

View File

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

View File

@@ -5,12 +5,7 @@ sidebar_position: 3
--- ---
# Unraid # 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 :::warning
Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages. Third-party installation methods are maintained by the community. The Seerr team is not responsible for these packages.
::: :::
@@ -19,9 +14,76 @@ 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. 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**. 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.
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. ## Fresh Installation
5. Click apply and access "Seerr" at your `<ServerIP:HostPort>` in a web browser.
--> ### 1. Create the config directory
:::note
Seerr is now rootless. Unraid typically runs Docker containers as `nobody:users` (UID 99, GID 100), but Seerr now runs internally as UID 1000, GID 1000. This creates a permission mismatch.
:::
:::info
**If migrating**: Copy your existing Jellyseerr/Overseerr config files (e.g., from `/mnt/user/appdata/overseerr/` or `/mnt/user/appdata/jellyseerr`) to `/mnt/user/appdata/seerr`, then apply the permissions below
:::
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. 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. 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 - **Snap package:** Not maintained
::: :::
@@ -200,14 +199,82 @@ Summary of changes :
</TabItem> </TabItem>
</Tabs> </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 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 See https://aur.archlinux.org/packages/seerr
### TrueNAS (Third-party installation methods) ### TrueNAS
Waiting for https://github.com/truenas/apps/issues/3374 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 ## 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 # Backup

View File

@@ -6,18 +6,22 @@ sidebar_position: 2
# Web Push # Web Push
The web push notification agent enables you and your users to receive Seerr notifications in a supported browser.
This notification agent does not require any configuration, but is not enabled in Seerr
:::warning :::warning
Web push notifications require a secure connection to your Seerr instance. Refer to the [Reverse Proxy](/extending-seerr/reverse-proxy) documentation for more information. Web push notifications require a secure connection to your Seerr instance. Refer to the [Reverse Proxy](/extending-seerr/reverse-proxy) documentation for more information.
::: :::
To set up web push notifications, simply enable the agent in **Settings → Notifications → Web Push**. You and your users will then be prompted to allow notifications in your web browser. The web push notification agent enables you and your users to receive Seerr notifications in a supported browser. This offers a native notification experience without the need to install an app.
Users can opt out of these notifications, or customize the notification types they would like to subscribe to, in their user settings. This notification agent does not require any configuration, but is not enabled by default in Seerr.
:::info To set up web push notifications, simply enable the agent in **Settings → Notifications → Web Push**.
Web push notifications offer a native notification experience without the need to install an app.
You and your users have the option to enable web push notifications by going to your **User Profile → Edit Settings → Notifications → Web Push → Enable web push**. Here you can also customize the notifications you'd like to receive.
:::info[Mobile Users]
For Web Push notifications to work on mobile you need to add Seerr to your home screen as progressive web app (PWA).
:::
:::info[iOS Users]
On iOS you may need to enable the Safari notifications feature flag by going to **Settings → Safari → Advanced → Feature Flags** and enabling "Notifications".
::: :::

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. 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 ## 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. 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. 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. * **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. * **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. * **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. * **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. * **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

View File

@@ -24,7 +24,8 @@
"prepare": "node bin/prepare.js", "prepare": "node bin/prepare.js",
"cypress:open": "cypress open", "cypress:open": "cypress open",
"cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts", "cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts",
"cypress:build": "pnpm build && pnpm cypress:prepare" "cypress:build": "pnpm build && pnpm cypress:prepare",
"db:migratetopostgres": "pnpm build:server && node dist/scripts/sqliteToPostgres.js"
}, },
"repository": { "repository": {
"type": "git", "type": "git",

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

View File

@@ -38,8 +38,8 @@ tags:
description: Endpoints related to getting service (Radarr/Sonarr) details. description: Endpoints related to getting service (Radarr/Sonarr) details.
- name: watchlist - name: watchlist
description: Collection of media to watch later description: Collection of media to watch later
- name: blacklist - name: blocklist
description: Blacklisted media from discovery page. description: Blocklisted media from discovery page.
servers: servers:
- url: '{server}/api/v1' - url: '{server}/api/v1'
variables: variables:
@@ -48,7 +48,7 @@ servers:
components: components:
schemas: schemas:
Blacklist: Blocklist:
type: object type: object
properties: properties:
tmdbId: tmdbId:
@@ -4529,12 +4529,12 @@ paths:
restricted: restricted:
type: boolean type: boolean
example: false example: false
/blacklist: /blocklist:
get: get:
summary: Returns blacklisted items summary: Returns blocklisted items
description: Returns list of all blacklisted media description: Returns list of all blocklisted media
tags: tags:
- settings - blocklist
parameters: parameters:
- in: query - in: query
name: take name: take
@@ -4558,11 +4558,11 @@ paths:
name: filter name: filter
schema: schema:
type: string type: string
enum: [all, manual, blacklistedTags] enum: [all, manual, blocklistedTags]
default: manual default: manual
responses: responses:
'200': '200':
description: Blacklisted items returned description: Blocklisted items returned
content: content:
application/json: application/json:
schema: schema:
@@ -4593,25 +4593,25 @@ paths:
type: number type: number
example: 438631 example: 438631
post: post:
summary: Add media to blacklist summary: Add media to blocklist
tags: tags:
- blacklist - blocklist
requestBody: requestBody:
required: true required: true
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Blacklist' $ref: '#/components/schemas/Blocklist'
responses: responses:
'201': '201':
description: Item succesfully blacklisted description: Item succesfully blocklisted
'412': '412':
description: Item has already been blacklisted description: Item has already been blocklisted
/blacklist/{tmdbId}: /blocklist/{tmdbId}:
get: get:
summary: Get media from blacklist summary: Get media from blocklist
tags: tags:
- blacklist - blocklist
parameters: parameters:
- in: path - in: path
name: tmdbId name: tmdbId
@@ -4622,11 +4622,131 @@ paths:
type: string type: string
responses: responses:
'200': '200':
description: Blacklist details in JSON description: Blocklist details in JSON
delete: delete:
summary: Remove media from blacklist summary: Remove media from blocklist
tags: 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: parameters:
- in: path - in: path
name: tmdbId name: tmdbId

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import {
AfterLoad, AfterLoad,
Column, Column,
Entity, Entity,
Index,
ManyToOne, ManyToOne,
OneToMany, OneToMany,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
@@ -19,6 +20,7 @@ class Issue {
public id: number; public id: number;
@Column({ type: 'int' }) @Column({ type: 'int' })
@Index()
public issueType: IssueType; public issueType: IssueType;
@Column({ type: 'int', default: IssueStatus.OPEN }) @Column({ type: 'int', default: IssueStatus.OPEN })
@@ -34,12 +36,14 @@ class Issue {
eager: true, eager: true,
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@Index()
public media: Media; public media: Media;
@ManyToOne(() => User, (user) => user.createdIssues, { @ManyToOne(() => User, (user) => user.createdIssues, {
eager: true, eager: true,
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@Index()
public createdBy: User; public createdBy: User;
@ManyToOne(() => User, { @ManyToOne(() => User, {
@@ -47,6 +51,7 @@ class Issue {
onDelete: 'CASCADE', onDelete: 'CASCADE',
nullable: true, nullable: true,
}) })
@Index()
public modifiedBy?: User; public modifiedBy?: User;
@OneToMany(() => IssueComment, (comment) => comment.issue, { @OneToMany(() => IssueComment, (comment) => comment.issue, {

View File

@@ -1,5 +1,11 @@
import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import {
Column,
Entity,
Index,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import Issue from './Issue'; import Issue from './Issue';
import { User } from './User'; import { User } from './User';
@@ -12,11 +18,13 @@ class IssueComment {
eager: true, eager: true,
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@Index()
public user: User; public user: User;
@ManyToOne(() => Issue, (issue) => issue.comments, { @ManyToOne(() => Issue, (issue) => issue.comments, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@Index()
public issue: Issue; public issue: Issue;
@Column({ type: 'text' }) @Column({ type: 'text' })

View File

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

View File

@@ -35,7 +35,7 @@ export class RequestPermissionError extends Error {}
export class QuotaRestrictedError extends Error {} export class QuotaRestrictedError extends Error {}
export class DuplicateMediaRequestError extends Error {} export class DuplicateMediaRequestError extends Error {}
export class NoSeasonsAvailableError extends Error {} export class NoSeasonsAvailableError extends Error {}
export class BlacklistedMediaError extends Error {} export class BlocklistedMediaError extends Error {}
type MediaRequestOptions = { type MediaRequestOptions = {
isAutoRequest?: boolean; isAutoRequest?: boolean;
@@ -140,14 +140,14 @@ export class MediaRequest {
mediaType: requestBody.mediaType, mediaType: requestBody.mediaType,
}); });
} else { } else {
if (media.status === MediaStatus.BLACKLISTED) { if (media.status === MediaStatus.BLOCKLISTED) {
logger.warn('Request for media blocked due to being blacklisted', { logger.warn('Request for media blocked due to being blocklisted', {
tmdbId: tmdbMedia.id, tmdbId: tmdbMedia.id,
mediaType: requestBody.mediaType, mediaType: requestBody.mediaType,
label: 'Media Request', label: 'Media Request',
}); });
throw new BlacklistedMediaError('This media is blacklisted.'); throw new BlocklistedMediaError('This media is blocklisted.');
} }
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) { if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
@@ -521,12 +521,14 @@ export class MediaRequest {
eager: true, eager: true,
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@Index()
public media: Media; public media: Media;
@ManyToOne(() => User, (user) => user.requests, { @ManyToOne(() => User, (user) => user.requests, {
eager: true, eager: true,
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@Index()
public requestedBy: User; public requestedBy: User;
@ManyToOne(() => User, { @ManyToOne(() => User, {
@@ -535,6 +537,7 @@ export class MediaRequest {
eager: true, eager: true,
onDelete: 'SET NULL', onDelete: 'SET NULL',
}) })
@Index()
public modifiedBy?: User; public modifiedBy?: User;
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })

View File

@@ -1,6 +1,12 @@
import { MediaStatus } from '@server/constants/media'; import { MediaStatus } from '@server/constants/media';
import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import {
Column,
Entity,
Index,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import Media from './Media'; import Media from './Media';
@Entity() @Entity()
@@ -20,6 +26,7 @@ class Season {
@ManyToOne(() => Media, (media) => media.seasons, { @ManyToOne(() => Media, (media) => media.seasons, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@Index()
public media: Promise<Media>; public media: Promise<Media>;
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })

View File

@@ -1,6 +1,12 @@
import { MediaRequestStatus } from '@server/constants/media'; import { MediaRequestStatus } from '@server/constants/media';
import { DbAwareColumn } from '@server/utils/DbColumnHelper'; import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import {
Column,
Entity,
Index,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { MediaRequest } from './MediaRequest'; import { MediaRequest } from './MediaRequest';
@Entity() @Entity()
@@ -17,6 +23,7 @@ class SeasonRequest {
@ManyToOne(() => MediaRequest, (request) => request.seasons, { @ManyToOne(() => MediaRequest, (request) => request.seasons, {
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@Index()
public request: MediaRequest; public request: MediaRequest;
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })

View File

@@ -2,6 +2,7 @@ import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { import {
Column, Column,
Entity, Entity,
Index,
ManyToOne, ManyToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
Unique, Unique,
@@ -18,6 +19,7 @@ export class UserPushSubscription {
eager: true, eager: true,
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@Index()
public user: User; public user: User;
@Column() @Column()

View File

@@ -47,12 +47,14 @@ export class Watchlist implements WatchlistItem {
eager: true, eager: true,
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@Index()
public requestedBy: User; public requestedBy: User;
@ManyToOne(() => Media, (media) => media.watchlists, { @ManyToOne(() => Media, (media) => media.watchlists, {
eager: true, eager: true,
onDelete: 'CASCADE', onDelete: 'CASCADE',
}) })
@Index()
public media: Media; public media: Media;
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })

View File

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

View File

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

View File

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

View File

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

View File

@@ -385,26 +385,6 @@ class BaseScanner<T> {
} }
} }
// We want to skip specials when checking if a show is available
const isAllStandardSeasons =
seasons.length &&
seasons
.filter((season) => season.seasonNumber !== 0)
.every(
(season) =>
season.episodes === season.totalEpisodes && season.episodes > 0
);
const isAll4kSeasons =
seasons.length &&
seasons
.filter((season) => season.seasonNumber !== 0)
.every(
(season) =>
season.episodes4k === season.totalEpisodes &&
season.episodes4k > 0
);
if (media) { if (media) {
media.seasons = [...media.seasons, ...newSeasons]; media.seasons = [...media.seasons, ...newSeasons];
@@ -464,43 +444,38 @@ class BaseScanner<T> {
externalServiceSlug; externalServiceSlug;
} }
// If the show is already available, and there are no new seasons, dont adjust const nonSpecialSeasons = media.seasons.filter(
// the status. Skip specials when performing availability check (s) => s.seasonNumber !== 0
const shouldStayAvailable = );
media.status === MediaStatus.AVAILABLE &&
newSeasons.filter( // Check the actual season objects instead scanner input
(season) => // to determine overall availability status
season.status !== MediaStatus.UNKNOWN && const isAllStandardSeasonsAvailable =
season.status !== MediaStatus.DELETED && nonSpecialSeasons.length > 0 &&
season.seasonNumber !== 0 nonSpecialSeasons.every((s) => s.status === MediaStatus.AVAILABLE);
).length === 0;
const shouldStayAvailable4k = const isAll4kSeasonsAvailable =
media.status4k === MediaStatus.AVAILABLE && nonSpecialSeasons.length > 0 &&
newSeasons.filter( nonSpecialSeasons.every((s) => s.status4k === MediaStatus.AVAILABLE);
(season) =>
season.status4k !== MediaStatus.UNKNOWN && media.status = isAllStandardSeasonsAvailable
season.status4k !== MediaStatus.DELETED && ? MediaStatus.AVAILABLE
season.seasonNumber !== 0 : media.seasons.some(
).length === 0; (season) =>
media.status = season.status === MediaStatus.PARTIALLY_AVAILABLE ||
isAllStandardSeasons || shouldStayAvailable season.status === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE )
: media.seasons.some( ? MediaStatus.PARTIALLY_AVAILABLE
(season) => : (!seasons.length && media.status !== MediaStatus.DELETED) ||
season.status === MediaStatus.PARTIALLY_AVAILABLE || media.seasons.some(
season.status === MediaStatus.AVAILABLE (season) => season.status === MediaStatus.PROCESSING
) )
? MediaStatus.PARTIALLY_AVAILABLE ? MediaStatus.PROCESSING
: (!seasons.length && media.status !== MediaStatus.DELETED) || : media.status === MediaStatus.DELETED
media.seasons.some( ? MediaStatus.DELETED
(season) => season.status === MediaStatus.PROCESSING : MediaStatus.UNKNOWN;
)
? MediaStatus.PROCESSING
: media.status === MediaStatus.DELETED
? MediaStatus.DELETED
: MediaStatus.UNKNOWN;
media.status4k = media.status4k =
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow isAll4kSeasonsAvailable && this.enable4kShow
? MediaStatus.AVAILABLE ? MediaStatus.AVAILABLE
: this.enable4kShow && : this.enable4kShow &&
media.seasons.some( media.seasons.some(
@@ -520,6 +495,22 @@ class BaseScanner<T> {
await mediaRepository.save(media); await mediaRepository.save(media);
this.log(`Updating existing title: ${title}`); this.log(`Updating existing title: ${title}`);
} else { } else {
// For new media, check actual newSeasons objects instead of scanner
// input to determine overall availability status
const nonSpecialNewSeasons = newSeasons.filter(
(s) => s.seasonNumber !== 0
);
const isAllStandardSeasonsAvailable =
nonSpecialNewSeasons.length > 0 &&
nonSpecialNewSeasons.every((s) => s.status === MediaStatus.AVAILABLE);
const isAll4kSeasonsAvailable =
nonSpecialNewSeasons.length > 0 &&
nonSpecialNewSeasons.every(
(s) => s.status4k === MediaStatus.AVAILABLE
);
const newMedia = new Media({ const newMedia = new Media({
mediaType: MediaType.TV, mediaType: MediaType.TV,
seasons: newSeasons, seasons: newSeasons,
@@ -564,7 +555,7 @@ class BaseScanner<T> {
) )
? jellyfinMediaId ? jellyfinMediaId
: undefined, : undefined,
status: isAllStandardSeasons status: isAllStandardSeasonsAvailable
? MediaStatus.AVAILABLE ? MediaStatus.AVAILABLE
: newSeasons.some( : newSeasons.some(
(season) => (season) =>
@@ -578,7 +569,7 @@ class BaseScanner<T> {
? MediaStatus.PROCESSING ? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN, : MediaStatus.UNKNOWN,
status4k: status4k:
isAll4kSeasons && this.enable4kShow isAll4kSeasonsAvailable && this.enable4kShow
? MediaStatus.AVAILABLE ? MediaStatus.AVAILABLE
: this.enable4kShow && : this.enable4kShow &&
newSeasons.some( newSeasons.some(

View File

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

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

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