Compare commits
1 Commits
migrate-to
...
renovate/n
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4898bdad9 |
4
.github/cliff.toml
vendored
@@ -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 -%}
|
||||
|
||||
|
||||
2
.github/renovate/helm.json5
vendored
@@ -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',
|
||||
},
|
||||
|
||||
2
.github/workflows/docs-deploy.yml
vendored
@@ -6,7 +6,7 @@ on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- legacy-jellyseerr
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- 'gen-docs/**'
|
||||
|
||||
20
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284 AS base
|
||||
FROM node:22.22.0-alpine3.22@sha256:7aa86fa052f6e4b101557ccb56717cb4311be1334381f526fe013418fe157384 AS base
|
||||
ARG SOURCE_DATE_EPOCH
|
||||
ARG TARGETPLATFORM
|
||||
ENV TARGETPLATFORM=${TARGETPLATFORM:-linux/amd64}
|
||||
@@ -33,7 +33,7 @@ RUN pnpm build
|
||||
|
||||
RUN rm -rf .next/cache
|
||||
|
||||
FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
|
||||
FROM node:22.22.0-alpine3.22@sha256:7aa86fa052f6e4b101557ccb56717cb4311be1334381f526fe013418fe157384
|
||||
ARG SOURCE_DATE_EPOCH
|
||||
ARG COMMIT_TAG
|
||||
ENV NODE_ENV=production
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22.22.0-alpine3.22@sha256:0c49915657c1c77c64c8af4d91d2f13fe96853bbd957993ed00dd592cbecc284
|
||||
FROM node:22.22.0-alpine3.22@sha256:7aa86fa052f6e4b101557ccb56717cb4311be1334381f526fe013418fe157384
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
36
README.md
@@ -1,6 +1,6 @@
|
||||
<p align="center">
|
||||
<img src="./public/logo_full.svg" alt="Seerr" style="margin: 20px 0;">
|
||||
</p>
|
||||
<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="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,25 +26,37 @@
|
||||
- Granular permission system.
|
||||
- Support for various notification agents.
|
||||
- Mobile-friendly design, for when you need to approve requests on the go!
|
||||
- Support for watchlisting & blocklisting media.
|
||||
- Support for watchlisting & blacklisting media.
|
||||
|
||||
With more features on the way! Check out our [issue tracker](/../../issues) to see the features which have already been requested.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Check out our documentation for instructions on how to install and run Seerr:
|
||||
For instructions on how to install and run **Jellyseerr**, please refer to the official documentation:
|
||||
|
||||
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" 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.
|
||||
<img src="./public/preview.jpg">
|
||||
|
||||
## Support
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ kubeVersion: '>=1.23.0-0'
|
||||
name: seerr-chart
|
||||
description: Seerr helm chart for Kubernetes
|
||||
type: application
|
||||
version: 3.1.0
|
||||
version: 3.0.0
|
||||
# renovate: image=ghcr.io/seerr-team/seerr
|
||||
appVersion: 'v3.0.1'
|
||||
appVersion: '3.0.0'
|
||||
maintainers:
|
||||
- name: Seerr Team
|
||||
url: https://github.com/orgs/seerr-team/people
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# seerr-chart
|
||||
|
||||
  
|
||||
  
|
||||
|
||||
Seerr helm chart for Kubernetes
|
||||
|
||||
@@ -44,10 +44,9 @@ If `replicaCount` value was used - remove it. Helm update should work fine after
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| affinity | object | `{}` | |
|
||||
| config | object | `{"persistence":{"accessModes":["ReadWriteOnce"],"annotations":{},"existingClaim":"","name":"","size":"5Gi","volumeName":""}}` | Creating PVC to store configuration |
|
||||
| config | object | `{"persistence":{"accessModes":["ReadWriteOnce"],"annotations":{},"name":"","size":"5Gi","volumeName":""}}` | Creating PVC to store configuration |
|
||||
| config.persistence.accessModes | list | `["ReadWriteOnce"]` | Access modes of persistent disk |
|
||||
| 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.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. |
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
{{- if not .Values.config.persistence.existingClaim -}}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
@@ -23,4 +22,3 @@ spec:
|
||||
resources:
|
||||
requests:
|
||||
storage: "{{ .Values.config.persistence.size }}"
|
||||
{{- end -}}
|
||||
@@ -103,7 +103,7 @@ spec:
|
||||
volumes:
|
||||
- name: config
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ if .Values.config.persistence.existingClaim }}{{ .Values.config.persistence.existingClaim }}{{- else }}{{ include "seerr.configPersistenceName" . }}{{- end }}
|
||||
claimName: {{ include "seerr.configPersistenceName" . }}
|
||||
{{- with .Values.volumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -86,8 +86,6 @@ config:
|
||||
# -- Name of the permanent volume to reference in the claim.
|
||||
# Can be used to bind to existing volumes.
|
||||
volumeName: ''
|
||||
# -- Specify an existing `PersistentVolumeClaim` to use. If this value is provided, the default PVC will not be created
|
||||
existingClaim: ''
|
||||
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
"discoverRegion": "",
|
||||
"streamingRegion": "",
|
||||
"originalLanguage": "",
|
||||
"blocklistedTags": "",
|
||||
"blocklistedTagsLimit": 50,
|
||||
"blacklistedTags": "",
|
||||
"blacklistedTagsLimit": 50,
|
||||
"trustProxy": false,
|
||||
"mediaServerType": 1,
|
||||
"partialRequestsEnabled": true,
|
||||
|
||||
@@ -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 **Blocklist** media.
|
||||
- Easily **Watchlist** or **Blacklist** media.
|
||||
- More features to come!
|
||||
|
||||
## We need your help!
|
||||
|
||||
@@ -5,7 +5,12 @@ 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.
|
||||
:::
|
||||
@@ -14,76 +19,9 @@ 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.
|
||||
:::
|
||||
|
||||
|
||||
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
|
||||
|
||||
:::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.
|
||||
:::
|
||||
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.
|
||||
-->
|
||||
|
||||
@@ -23,6 +23,7 @@ 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
|
||||
:::
|
||||
|
||||
@@ -199,82 +200,14 @@ Summary of changes :
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Third-party installation methods
|
||||
### Nix
|
||||
### Nix (Third-party installation methods)
|
||||
|
||||
Waiting for https://github.com/NixOS/nixpkgs/pull/450096 and https://github.com/NixOS/nixpkgs/pull/450093
|
||||
|
||||
### AUR
|
||||
### AUR (Third-party installation methods)
|
||||
|
||||
See https://aur.archlinux.org/packages/seerr
|
||||
|
||||
### TrueNAS
|
||||
### TrueNAS (Third-party installation methods)
|
||||
|
||||
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`.
|
||||
:::
|
||||
@@ -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, blocklist etc. are stored in the database (either SQLite or PostgreSQL).
|
||||
Apart from the settings, all other data—including user accounts, media requests, blacklist etc. are stored in the database (either SQLite or PostgreSQL).
|
||||
|
||||
# Backup
|
||||
|
||||
|
||||
@@ -6,22 +6,18 @@ sidebar_position: 2
|
||||
|
||||
# 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
|
||||
Web push notifications require a secure connection to your Seerr instance. Refer to the [Reverse Proxy](/extending-seerr/reverse-proxy) documentation for more information.
|
||||
:::
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
This notification agent does not require any configuration, but is not enabled by default in Seerr.
|
||||
Users can opt out of these notifications, or customize the notification types they would like to subscribe to, in their user settings.
|
||||
|
||||
To set up web push notifications, simply enable the agent in **Settings → Notifications → Web Push**.
|
||||
|
||||
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".
|
||||
:::info
|
||||
Web push notifications offer a native notification experience without the need to install an app.
|
||||
:::
|
||||
|
||||
@@ -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.
|
||||
|
||||
## Blocklist Content with Tags and Limit Content Blocklisted per Tag
|
||||
## Blacklist Content with Tags and Limit Content Blacklisted per Tag
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Blocklisted tags are disabled until at least one tag is entered. These settings cannot be overridden in user settings.
|
||||
Blacklisted 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 Blocklisted Items
|
||||
## Hide Blacklisted Items
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
This setting is **disabled** by default.
|
||||
|
||||
|
||||
@@ -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.
|
||||
* **Blocklist for movies, series, and tags**: Allows permitted users to hide movies, series, or tags from regular users.
|
||||
* **Blacklist 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.
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 22 KiB |
@@ -24,8 +24,7 @@
|
||||
"prepare": "node bin/prepare.js",
|
||||
"cypress:open": "cypress open",
|
||||
"cypress:prepare": "ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/scripts/prepareTestDb.ts",
|
||||
"cypress:build": "pnpm build && pnpm cypress:prepare",
|
||||
"db:migratetopostgres": "pnpm build:server && node dist/scripts/sqliteToPostgres.js"
|
||||
"cypress:build": "pnpm build && pnpm cypress:prepare"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 38 KiB |
BIN
public/apple-splash-1179-2556.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 43 KiB |
BIN
public/apple-splash-1290-2796.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
BIN
public/apple-splash-1488-2266.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 47 KiB |
BIN
public/apple-splash-1640-2360.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 47 KiB |
BIN
public/apple-splash-2266-1488.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/apple-splash-2360-1640.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 33 KiB |
BIN
public/apple-splash-2556-1179.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 37 KiB |
BIN
public/apple-splash-2796-1290.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 774 B After Width: | Height: | Size: 821 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.2 KiB |
BIN
public/os_logo_square.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 137 KiB |
@@ -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 = 5;
|
||||
const OFFLINE_VERSION = 4;
|
||||
const CACHE_NAME = 'offline';
|
||||
// Customize this with a different URL if needed.
|
||||
const OFFLINE_URL = '/offline.html';
|
||||
|
||||
156
seerr-api.yml
@@ -38,8 +38,8 @@ tags:
|
||||
description: Endpoints related to getting service (Radarr/Sonarr) details.
|
||||
- name: watchlist
|
||||
description: Collection of media to watch later
|
||||
- name: blocklist
|
||||
description: Blocklisted media from discovery page.
|
||||
- name: blacklist
|
||||
description: Blacklisted media from discovery page.
|
||||
servers:
|
||||
- url: '{server}/api/v1'
|
||||
variables:
|
||||
@@ -48,7 +48,7 @@ servers:
|
||||
|
||||
components:
|
||||
schemas:
|
||||
Blocklist:
|
||||
Blacklist:
|
||||
type: object
|
||||
properties:
|
||||
tmdbId:
|
||||
@@ -4529,123 +4529,12 @@ paths:
|
||||
restricted:
|
||||
type: boolean
|
||||
example: false
|
||||
/blocklist:
|
||||
get:
|
||||
summary: Returns blocklisted items
|
||||
description: Returns list of all blocklisted media
|
||||
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
|
||||
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
|
||||
/blocklist/{tmdbId}:
|
||||
get:
|
||||
summary: Get media from blocklist
|
||||
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
|
||||
tags:
|
||||
- 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
|
||||
summary: Returns blacklisted items
|
||||
description: Returns list of all blacklisted media
|
||||
tags:
|
||||
- blocklist
|
||||
- settings
|
||||
parameters:
|
||||
- in: query
|
||||
name: take
|
||||
@@ -4669,11 +4558,11 @@ paths:
|
||||
name: filter
|
||||
schema:
|
||||
type: string
|
||||
enum: [all, manual, blocklistedTags]
|
||||
enum: [all, manual, blacklistedTags]
|
||||
default: manual
|
||||
responses:
|
||||
'200':
|
||||
description: Blocklisted items returned
|
||||
description: Blacklisted items returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -4704,31 +4593,25 @@ paths:
|
||||
type: number
|
||||
example: 438631
|
||||
post:
|
||||
summary: Add media to blocklist
|
||||
description: |
|
||||
**DEPRECATED**: Use `/blocklist` instead. This endpoint will be deprecated soon.
|
||||
deprecated: true
|
||||
summary: Add media to blacklist
|
||||
tags:
|
||||
- blocklist
|
||||
- blacklist
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Blocklist'
|
||||
$ref: '#/components/schemas/Blacklist'
|
||||
responses:
|
||||
'201':
|
||||
description: Item succesfully blocklisted
|
||||
description: Item succesfully blacklisted
|
||||
'412':
|
||||
description: Item has already been blocklisted
|
||||
description: Item has already been blacklisted
|
||||
/blacklist/{tmdbId}:
|
||||
get:
|
||||
summary: Get media from blocklist
|
||||
description: |
|
||||
**DEPRECATED**: Use `/blocklist/{tmdbId}` instead. This endpoint will be deprecated soon.
|
||||
deprecated: true
|
||||
summary: Get media from blacklist
|
||||
tags:
|
||||
- blocklist
|
||||
- blacklist
|
||||
parameters:
|
||||
- in: path
|
||||
name: tmdbId
|
||||
@@ -4739,14 +4622,11 @@ paths:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Blocklist details in JSON
|
||||
description: Blacklist details in JSON
|
||||
delete:
|
||||
summary: Remove media from blocklist
|
||||
description: |
|
||||
**DEPRECATED**: Use `/blocklist/{tmdbId}` instead. This endpoint will be deprecated soon.
|
||||
deprecated: true
|
||||
summary: Remove media from blacklist
|
||||
tags:
|
||||
- blocklist
|
||||
- blacklist
|
||||
parameters:
|
||||
- in: path
|
||||
name: tmdbId
|
||||
|
||||
@@ -92,7 +92,7 @@ class ServarrBase<QueueItemAppendT> extends ExternalAPI {
|
||||
apiKey,
|
||||
cacheName,
|
||||
apiName,
|
||||
timeout = 10000,
|
||||
timeout = 5000,
|
||||
}: {
|
||||
url: string;
|
||||
apiKey: string;
|
||||
|
||||
@@ -17,6 +17,6 @@ export enum MediaStatus {
|
||||
PROCESSING,
|
||||
PARTIALLY_AVAILABLE,
|
||||
AVAILABLE,
|
||||
BLOCKLISTED,
|
||||
BLACKLISTED,
|
||||
DELETED,
|
||||
}
|
||||
|
||||
@@ -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 { BlocklistItem } from '@server/interfaces/api/blocklistInterfaces';
|
||||
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
|
||||
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 Blocklist implements BlocklistItem {
|
||||
export class Blacklist implements BlacklistItem {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@@ -36,68 +36,67 @@ export class Blocklist implements BlocklistItem {
|
||||
@ManyToOne(() => User, (user) => user.id, {
|
||||
eager: true,
|
||||
})
|
||||
@Index()
|
||||
user?: User;
|
||||
|
||||
@OneToOne(() => Media, (media) => media.blocklist, {
|
||||
@OneToOne(() => Media, (media) => media.blacklist, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public media: Media;
|
||||
|
||||
@Column({ nullable: true, type: 'varchar' })
|
||||
public blocklistedTags?: string;
|
||||
public blacklistedTags?: string;
|
||||
|
||||
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||
public createdAt: Date;
|
||||
|
||||
constructor(init?: Partial<Blocklist>) {
|
||||
constructor(init?: Partial<Blacklist>) {
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
public static async addToBlocklist(
|
||||
public static async addToBlacklist(
|
||||
{
|
||||
blocklistRequest,
|
||||
blacklistRequest,
|
||||
}: {
|
||||
blocklistRequest: {
|
||||
blacklistRequest: {
|
||||
mediaType: MediaType;
|
||||
title?: ZodOptional<ZodString>['_output'];
|
||||
tmdbId: ZodNumber['_output'];
|
||||
blocklistedTags?: string;
|
||||
blacklistedTags?: string;
|
||||
};
|
||||
},
|
||||
entityManager?: EntityManager
|
||||
): Promise<void> {
|
||||
const em = entityManager ?? dataSource;
|
||||
const blocklist = new this({
|
||||
...blocklistRequest,
|
||||
const blacklist = new this({
|
||||
...blacklistRequest,
|
||||
});
|
||||
|
||||
const mediaRepository = em.getRepository(Media);
|
||||
let media = await mediaRepository.findOne({
|
||||
where: {
|
||||
tmdbId: blocklistRequest.tmdbId,
|
||||
tmdbId: blacklistRequest.tmdbId,
|
||||
},
|
||||
});
|
||||
|
||||
const blocklistRepository = em.getRepository(this);
|
||||
const blacklistRepository = em.getRepository(this);
|
||||
|
||||
await blocklistRepository.save(blocklist);
|
||||
await blacklistRepository.save(blacklist);
|
||||
|
||||
if (!media) {
|
||||
media = new Media({
|
||||
tmdbId: blocklistRequest.tmdbId,
|
||||
status: MediaStatus.BLOCKLISTED,
|
||||
status4k: MediaStatus.BLOCKLISTED,
|
||||
mediaType: blocklistRequest.mediaType,
|
||||
blocklist: Promise.resolve(blocklist),
|
||||
tmdbId: blacklistRequest.tmdbId,
|
||||
status: MediaStatus.BLACKLISTED,
|
||||
status4k: MediaStatus.BLACKLISTED,
|
||||
mediaType: blacklistRequest.mediaType,
|
||||
blacklist: Promise.resolve(blacklist),
|
||||
});
|
||||
|
||||
await mediaRepository.save(media);
|
||||
} else {
|
||||
media.blocklist = Promise.resolve(blocklist);
|
||||
media.status = MediaStatus.BLOCKLISTED;
|
||||
media.status4k = MediaStatus.BLOCKLISTED;
|
||||
media.blacklist = Promise.resolve(blacklist);
|
||||
media.status = MediaStatus.BLACKLISTED;
|
||||
media.status4k = MediaStatus.BLACKLISTED;
|
||||
|
||||
await mediaRepository.save(media);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
AfterLoad,
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
@@ -20,7 +19,6 @@ class Issue {
|
||||
public id: number;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
@Index()
|
||||
public issueType: IssueType;
|
||||
|
||||
@Column({ type: 'int', default: IssueStatus.OPEN })
|
||||
@@ -36,14 +34,12 @@ class Issue {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public media: Media;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.createdIssues, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public createdBy: User;
|
||||
|
||||
@ManyToOne(() => User, {
|
||||
@@ -51,7 +47,6 @@ class Issue {
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true,
|
||||
})
|
||||
@Index()
|
||||
public modifiedBy?: User;
|
||||
|
||||
@OneToMany(() => IssueComment, (comment) => comment.issue, {
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import Issue from './Issue';
|
||||
import { User } from './User';
|
||||
|
||||
@@ -18,13 +12,11 @@ class IssueComment {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public user: User;
|
||||
|
||||
@ManyToOne(() => Issue, (issue) => issue.comments, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public issue: Issue;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
|
||||
@@ -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 { Blocklist } from '@server/entity/Blocklist';
|
||||
import { Blacklist } from '@server/entity/Blacklist';
|
||||
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(() => Blocklist, (blocklist) => blocklist.media)
|
||||
public blocklist: Promise<Blocklist>;
|
||||
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
|
||||
public blacklist: Promise<Blacklist>;
|
||||
|
||||
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||
public createdAt: Date;
|
||||
|
||||
@@ -35,7 +35,7 @@ export class RequestPermissionError extends Error {}
|
||||
export class QuotaRestrictedError extends Error {}
|
||||
export class DuplicateMediaRequestError extends Error {}
|
||||
export class NoSeasonsAvailableError extends Error {}
|
||||
export class BlocklistedMediaError extends Error {}
|
||||
export class BlacklistedMediaError extends Error {}
|
||||
|
||||
type MediaRequestOptions = {
|
||||
isAutoRequest?: boolean;
|
||||
@@ -140,14 +140,14 @@ export class MediaRequest {
|
||||
mediaType: requestBody.mediaType,
|
||||
});
|
||||
} else {
|
||||
if (media.status === MediaStatus.BLOCKLISTED) {
|
||||
logger.warn('Request for media blocked due to being blocklisted', {
|
||||
if (media.status === MediaStatus.BLACKLISTED) {
|
||||
logger.warn('Request for media blocked due to being blacklisted', {
|
||||
tmdbId: tmdbMedia.id,
|
||||
mediaType: requestBody.mediaType,
|
||||
label: 'Media Request',
|
||||
});
|
||||
|
||||
throw new BlocklistedMediaError('This media is blocklisted.');
|
||||
throw new BlacklistedMediaError('This media is blacklisted.');
|
||||
}
|
||||
|
||||
if (media.status === MediaStatus.UNKNOWN && !requestBody.is4k) {
|
||||
@@ -521,14 +521,12 @@ export class MediaRequest {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public media: Media;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.requests, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public requestedBy: User;
|
||||
|
||||
@ManyToOne(() => User, {
|
||||
@@ -537,7 +535,6 @@ export class MediaRequest {
|
||||
eager: true,
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
@Index()
|
||||
public modifiedBy?: User;
|
||||
|
||||
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { MediaStatus } from '@server/constants/media';
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import Media from './Media';
|
||||
|
||||
@Entity()
|
||||
@@ -26,7 +20,6 @@ class Season {
|
||||
@ManyToOne(() => Media, (media) => media.seasons, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public media: Promise<Media>;
|
||||
|
||||
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { MediaRequestStatus } from '@server/constants/media';
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
|
||||
@Entity()
|
||||
@@ -23,7 +17,6 @@ class SeasonRequest {
|
||||
@ManyToOne(() => MediaRequest, (request) => request.seasons, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public request: MediaRequest;
|
||||
|
||||
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||
|
||||
@@ -2,7 +2,6 @@ import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
@@ -19,7 +18,6 @@ export class UserPushSubscription {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public user: User;
|
||||
|
||||
@Column()
|
||||
|
||||
@@ -47,14 +47,12 @@ export class Watchlist implements WatchlistItem {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public requestedBy: User;
|
||||
|
||||
@ManyToOne(() => Media, (media) => media.watchlists, {
|
||||
eager: true,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@Index()
|
||||
public media: Media;
|
||||
|
||||
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { User } from '@server/entity/User';
|
||||
import type { PaginatedResponse } from '@server/interfaces/api/common';
|
||||
|
||||
export interface BlocklistItem {
|
||||
export interface BlacklistItem {
|
||||
tmdbId: number;
|
||||
mediaType: 'movie' | 'tv';
|
||||
title?: string;
|
||||
createdAt?: Date;
|
||||
user?: User;
|
||||
blocklistedTags?: string;
|
||||
blacklistedTags?: string;
|
||||
}
|
||||
|
||||
export interface BlocklistResultsResponse extends PaginatedResponse {
|
||||
results: BlocklistItem[];
|
||||
export interface BlacklistResultsResponse extends PaginatedResponse {
|
||||
results: BlacklistItem[];
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export interface PublicSettingsResponse {
|
||||
applicationTitle: string;
|
||||
applicationUrl: string;
|
||||
hideAvailable: boolean;
|
||||
hideBlocklisted: boolean;
|
||||
hideBlacklisted: boolean;
|
||||
localLogin: boolean;
|
||||
mediaServerLogin: boolean;
|
||||
movie4kEnabled: boolean;
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import dataSource from '@server/datasource';
|
||||
import { Blocklist } from '@server/entity/Blocklist';
|
||||
import { Blacklist } from '@server/entity/Blacklist';
|
||||
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 BlocklistedTagProcessor implements RunnableScanner<StatusBase> {
|
||||
class BlacklistedTagProcessor implements RunnableScanner<StatusBase> {
|
||||
private running = false;
|
||||
private progress = 0;
|
||||
private total = 0;
|
||||
@@ -30,12 +30,12 @@ class BlocklistedTagProcessor implements RunnableScanner<StatusBase> {
|
||||
|
||||
try {
|
||||
await dataSource.transaction(async (em) => {
|
||||
await this.cleanBlocklist(em);
|
||||
await this.createBlocklistEntries(em);
|
||||
await this.cleanBlacklist(em);
|
||||
await this.createBlacklistEntries(em);
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof AbortTransaction) {
|
||||
logger.info('Aborting job: Process Blocklisted Tags', {
|
||||
logger.info('Aborting job: Process Blacklisted Tags', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
} else {
|
||||
@@ -64,37 +64,37 @@ class BlocklistedTagProcessor implements RunnableScanner<StatusBase> {
|
||||
this.cancel();
|
||||
}
|
||||
|
||||
private async createBlocklistEntries(em: EntityManager) {
|
||||
private async createBlacklistEntries(em: EntityManager) {
|
||||
const tmdb = createTmdbWithRegionLanguage();
|
||||
|
||||
const settings = getSettings();
|
||||
const blocklistedTags = settings.main.blocklistedTags;
|
||||
const blocklistedTagsArr = blocklistedTags.split(',');
|
||||
const blacklistedTags = settings.main.blacklistedTags;
|
||||
const blacklistedTagsArr = blacklistedTags.split(',');
|
||||
|
||||
const pageLimit = settings.main.blocklistedTagsLimit;
|
||||
const pageLimit = settings.main.blacklistedTagsLimit;
|
||||
const invalidKeywords = new Set<string>();
|
||||
|
||||
if (blocklistedTags.length === 0) {
|
||||
if (blacklistedTags.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The maximum number of queries we're expected to execute
|
||||
this.total =
|
||||
2 * blocklistedTagsArr.length * pageLimit * SortOptionsIterable.length;
|
||||
2 * blacklistedTagsArr.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 blocklistedTagsArr) {
|
||||
for (const tag of blacklistedTagsArr) {
|
||||
const keywordDetails = await tmdb.getKeywordDetails({
|
||||
keywordId: Number(tag),
|
||||
});
|
||||
|
||||
if (keywordDetails === null) {
|
||||
logger.warn('Skipping invalid keyword in blocklisted tags', {
|
||||
label: 'Blocklisted Tags Processor',
|
||||
logger.warn('Skipping invalid keyword in blacklisted tags', {
|
||||
label: 'Blacklisted Tags Processor',
|
||||
keywordId: tag,
|
||||
});
|
||||
invalidKeywords.add(tag);
|
||||
@@ -134,8 +134,8 @@ class BlocklistedTagProcessor implements RunnableScanner<StatusBase> {
|
||||
queryMax = response.total_pages;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing keyword in blocklisted tags', {
|
||||
label: 'Blocklisted Tags Processor',
|
||||
logger.error('Error processing keyword in blacklisted tags', {
|
||||
label: 'Blacklisted Tags Processor',
|
||||
keywordId: tag,
|
||||
errorMessage: error.message,
|
||||
});
|
||||
@@ -145,19 +145,19 @@ class BlocklistedTagProcessor implements RunnableScanner<StatusBase> {
|
||||
}
|
||||
|
||||
if (invalidKeywords.size > 0) {
|
||||
const currentTags = blocklistedTagsArr.filter(
|
||||
const currentTags = blacklistedTagsArr.filter(
|
||||
(tag) => !invalidKeywords.has(tag)
|
||||
);
|
||||
const cleanedTags = currentTags.join(',');
|
||||
|
||||
if (cleanedTags !== blocklistedTags) {
|
||||
settings.main.blocklistedTags = cleanedTags;
|
||||
if (cleanedTags !== blacklistedTags) {
|
||||
settings.main.blacklistedTags = cleanedTags;
|
||||
await settings.save();
|
||||
|
||||
logger.info('Cleaned up invalid keywords from settings', {
|
||||
label: 'Blocklisted Tags Processor',
|
||||
label: 'Blacklisted Tags Processor',
|
||||
removedKeywords: Array.from(invalidKeywords),
|
||||
newBlocklistedTags: cleanedTags,
|
||||
newBlacklistedTags: cleanedTags,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -169,33 +169,33 @@ class BlocklistedTagProcessor implements RunnableScanner<StatusBase> {
|
||||
mediaType: MediaType,
|
||||
em: EntityManager
|
||||
) {
|
||||
const blocklistRepository = em.getRepository(Blocklist);
|
||||
const blacklistRepository = em.getRepository(Blacklist);
|
||||
|
||||
for (const entry of response.results) {
|
||||
const blocklistEntry = await blocklistRepository.findOne({
|
||||
const blacklistEntry = await blacklistRepository.findOne({
|
||||
where: { tmdbId: entry.id },
|
||||
});
|
||||
|
||||
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) {
|
||||
// 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.blocklistedTags &&
|
||||
!blocklistEntry.blocklistedTags.includes(`,${keywordId},`)
|
||||
blacklistEntry.blacklistedTags &&
|
||||
!blacklistEntry.blacklistedTags.includes(`,${keywordId},`)
|
||||
) {
|
||||
await blocklistRepository.update(blocklistEntry.id, {
|
||||
blocklistedTags: `${blocklistEntry.blocklistedTags}${keywordId},`,
|
||||
await blacklistRepository.update(blacklistEntry.id, {
|
||||
blacklistedTags: `${blacklistEntry.blacklistedTags}${keywordId},`,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Media wasn't previously blocklisted, add it to the blocklist
|
||||
await Blocklist.addToBlocklist(
|
||||
// Media wasn't previously blacklisted, add it to the blacklist
|
||||
await Blacklist.addToBlacklist(
|
||||
{
|
||||
blocklistRequest: {
|
||||
blacklistRequest: {
|
||||
mediaType,
|
||||
title: 'title' in entry ? entry.title : entry.name,
|
||||
tmdbId: entry.id,
|
||||
blocklistedTags: `,${keywordId},`,
|
||||
blacklistedTags: `,${keywordId},`,
|
||||
},
|
||||
},
|
||||
em
|
||||
@@ -204,22 +204,22 @@ class BlocklistedTagProcessor implements RunnableScanner<StatusBase> {
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanBlocklist(em: EntityManager) {
|
||||
// Remove blocklist and media entries blocklisted by tags
|
||||
private async cleanBlacklist(em: EntityManager) {
|
||||
// Remove blacklist and media entries blacklisted by tags
|
||||
const mediaRepository = em.getRepository(Media);
|
||||
const mediaToRemove = await mediaRepository
|
||||
.createQueryBuilder('media')
|
||||
.innerJoinAndSelect(Blocklist, 'blist', 'blist.tmdbId = media.tmdbId')
|
||||
.where(`blist.blocklistedTags IS NOT NULL`)
|
||||
.innerJoinAndSelect(Blacklist, 'blist', 'blist.tmdbId = media.tmdbId')
|
||||
.where(`blist.blacklistedTags 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 blocklist entries via cascading
|
||||
await mediaRepository.remove(mediaToRemove.slice(i, i + 500)); // This also deletes the blacklist entries via cascading
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blocklistedTagsProcessor = new BlocklistedTagProcessor();
|
||||
const blacklistedTagsProcessor = new BlacklistedTagProcessor();
|
||||
|
||||
export default blocklistedTagsProcessor;
|
||||
export default blacklistedTagsProcessor;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import blocklistedTagsProcessor from '@server/job/blocklistedTagsProcessor';
|
||||
import blacklistedTagsProcessor from '@server/job/blacklistedTagsProcessor';
|
||||
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-blocklisted-tags',
|
||||
name: 'Process Blocklisted Tags',
|
||||
id: 'process-blacklisted-tags',
|
||||
name: 'Process Blacklisted Tags',
|
||||
type: 'process',
|
||||
interval: 'days',
|
||||
cronSchedule: jobs['process-blocklisted-tags'].schedule,
|
||||
job: schedule.scheduleJob(jobs['process-blocklisted-tags'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Process Blocklisted Tags', {
|
||||
cronSchedule: jobs['process-blacklisted-tags'].schedule,
|
||||
job: schedule.scheduleJob(jobs['process-blacklisted-tags'].schedule, () => {
|
||||
logger.info('Starting scheduled job: Process Blacklisted Tags', {
|
||||
label: 'Jobs',
|
||||
});
|
||||
blocklistedTagsProcessor.run();
|
||||
blacklistedTagsProcessor.run();
|
||||
}),
|
||||
running: () => blocklistedTagsProcessor.status().running,
|
||||
cancelFn: () => blocklistedTagsProcessor.cancel(),
|
||||
running: () => blacklistedTagsProcessor.status().running,
|
||||
cancelFn: () => blacklistedTagsProcessor.cancel(),
|
||||
});
|
||||
|
||||
logger.info('Scheduled jobs loaded', { label: 'Jobs' });
|
||||
|
||||
@@ -27,8 +27,8 @@ export enum Permission {
|
||||
AUTO_REQUEST_TV = 33554432,
|
||||
RECENT_VIEW = 67108864,
|
||||
WATCHLIST_VIEW = 134217728,
|
||||
MANAGE_BLOCKLIST = 268435456,
|
||||
VIEW_BLOCKLIST = 1073741824,
|
||||
MANAGE_BLACKLIST = 268435456,
|
||||
VIEW_BLACKLIST = 1073741824,
|
||||
}
|
||||
|
||||
export interface PermissionCheckOptions {
|
||||
|
||||
@@ -385,6 +385,26 @@ 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) {
|
||||
media.seasons = [...media.seasons, ...newSeasons];
|
||||
|
||||
@@ -444,38 +464,43 @@ class BaseScanner<T> {
|
||||
externalServiceSlug;
|
||||
}
|
||||
|
||||
const nonSpecialSeasons = media.seasons.filter(
|
||||
(s) => s.seasonNumber !== 0
|
||||
);
|
||||
|
||||
// Check the actual season objects instead scanner input
|
||||
// to determine overall availability status
|
||||
const isAllStandardSeasonsAvailable =
|
||||
nonSpecialSeasons.length > 0 &&
|
||||
nonSpecialSeasons.every((s) => s.status === MediaStatus.AVAILABLE);
|
||||
|
||||
const isAll4kSeasonsAvailable =
|
||||
nonSpecialSeasons.length > 0 &&
|
||||
nonSpecialSeasons.every((s) => s.status4k === MediaStatus.AVAILABLE);
|
||||
|
||||
media.status = isAllStandardSeasonsAvailable
|
||||
? MediaStatus.AVAILABLE
|
||||
: media.seasons.some(
|
||||
(season) =>
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: (!seasons.length && media.status !== MediaStatus.DELETED) ||
|
||||
media.seasons.some(
|
||||
(season) => season.status === MediaStatus.PROCESSING
|
||||
// If the show is already available, and there are no new seasons, dont adjust
|
||||
// the status. Skip specials when performing availability check
|
||||
const shouldStayAvailable =
|
||||
media.status === MediaStatus.AVAILABLE &&
|
||||
newSeasons.filter(
|
||||
(season) =>
|
||||
season.status !== MediaStatus.UNKNOWN &&
|
||||
season.status !== MediaStatus.DELETED &&
|
||||
season.seasonNumber !== 0
|
||||
).length === 0;
|
||||
const shouldStayAvailable4k =
|
||||
media.status4k === MediaStatus.AVAILABLE &&
|
||||
newSeasons.filter(
|
||||
(season) =>
|
||||
season.status4k !== MediaStatus.UNKNOWN &&
|
||||
season.status4k !== MediaStatus.DELETED &&
|
||||
season.seasonNumber !== 0
|
||||
).length === 0;
|
||||
media.status =
|
||||
isAllStandardSeasons || shouldStayAvailable
|
||||
? MediaStatus.AVAILABLE
|
||||
: media.seasons.some(
|
||||
(season) =>
|
||||
season.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
season.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: media.status === MediaStatus.DELETED
|
||||
? MediaStatus.DELETED
|
||||
: MediaStatus.UNKNOWN;
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: (!seasons.length && media.status !== MediaStatus.DELETED) ||
|
||||
media.seasons.some(
|
||||
(season) => season.status === MediaStatus.PROCESSING
|
||||
)
|
||||
? MediaStatus.PROCESSING
|
||||
: media.status === MediaStatus.DELETED
|
||||
? MediaStatus.DELETED
|
||||
: MediaStatus.UNKNOWN;
|
||||
media.status4k =
|
||||
isAll4kSeasonsAvailable && this.enable4kShow
|
||||
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow &&
|
||||
media.seasons.some(
|
||||
@@ -495,22 +520,6 @@ class BaseScanner<T> {
|
||||
await mediaRepository.save(media);
|
||||
this.log(`Updating existing title: ${title}`);
|
||||
} 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({
|
||||
mediaType: MediaType.TV,
|
||||
seasons: newSeasons,
|
||||
@@ -555,7 +564,7 @@ class BaseScanner<T> {
|
||||
)
|
||||
? jellyfinMediaId
|
||||
: undefined,
|
||||
status: isAllStandardSeasonsAvailable
|
||||
status: isAllStandardSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) =>
|
||||
@@ -569,7 +578,7 @@ class BaseScanner<T> {
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
isAll4kSeasonsAvailable && this.enable4kShow
|
||||
isAll4kSeasons && this.enable4kShow
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow &&
|
||||
newSeasons.some(
|
||||
|
||||
@@ -132,15 +132,15 @@ export interface MainSettings {
|
||||
tv: Quota;
|
||||
};
|
||||
hideAvailable: boolean;
|
||||
hideBlocklisted: boolean;
|
||||
hideBlacklisted: boolean;
|
||||
localLogin: boolean;
|
||||
mediaServerLogin: boolean;
|
||||
newPlexLogin: boolean;
|
||||
discoverRegion: string;
|
||||
streamingRegion: string;
|
||||
originalLanguage: string;
|
||||
blocklistedTags: string;
|
||||
blocklistedTagsLimit: number;
|
||||
blacklistedTags: string;
|
||||
blacklistedTagsLimit: number;
|
||||
mediaServerType: number;
|
||||
partialRequestsEnabled: boolean;
|
||||
enableSpecialEpisodes: boolean;
|
||||
@@ -181,7 +181,7 @@ interface FullPublicSettings extends PublicSettings {
|
||||
applicationTitle: string;
|
||||
applicationUrl: string;
|
||||
hideAvailable: boolean;
|
||||
hideBlocklisted: boolean;
|
||||
hideBlacklisted: boolean;
|
||||
localLogin: boolean;
|
||||
mediaServerLogin: boolean;
|
||||
movie4kEnabled: boolean;
|
||||
@@ -346,7 +346,7 @@ export type JobId =
|
||||
| 'jellyfin-full-scan'
|
||||
| 'image-cache-cleanup'
|
||||
| 'availability-sync'
|
||||
| 'process-blocklisted-tags';
|
||||
| 'process-blacklisted-tags';
|
||||
|
||||
export interface AllSettings {
|
||||
clientId: string;
|
||||
@@ -389,15 +389,15 @@ class Settings {
|
||||
tv: {},
|
||||
},
|
||||
hideAvailable: false,
|
||||
hideBlocklisted: false,
|
||||
hideBlacklisted: false,
|
||||
localLogin: true,
|
||||
mediaServerLogin: true,
|
||||
newPlexLogin: true,
|
||||
discoverRegion: '',
|
||||
streamingRegion: '',
|
||||
originalLanguage: '',
|
||||
blocklistedTags: '',
|
||||
blocklistedTagsLimit: 50,
|
||||
blacklistedTags: '',
|
||||
blacklistedTagsLimit: 50,
|
||||
mediaServerType: MediaServerType.NOT_CONFIGURED,
|
||||
partialRequestsEnabled: true,
|
||||
enableSpecialEpisodes: false,
|
||||
@@ -570,7 +570,7 @@ class Settings {
|
||||
'image-cache-cleanup': {
|
||||
schedule: '0 0 5 * * *',
|
||||
},
|
||||
'process-blocklisted-tags': {
|
||||
'process-blacklisted-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,
|
||||
hideBlocklisted: this.data.main.hideBlocklisted,
|
||||
hideBlacklisted: this.data.main.hideBlacklisted,
|
||||
localLogin: this.data.main.localLogin,
|
||||
mediaServerLogin: this.data.main.mediaServerLogin,
|
||||
jellyfinExternalHost: this.data.jellyfin.externalHostname,
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
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;
|
||||
@@ -3,7 +3,7 @@ import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import {
|
||||
BlocklistedMediaError,
|
||||
BlacklistedMediaError,
|
||||
DuplicateMediaRequestError,
|
||||
MediaRequest,
|
||||
NoSeasonsAvailableError,
|
||||
@@ -145,8 +145,8 @@ class WatchlistSync {
|
||||
errorMessage: e.message,
|
||||
});
|
||||
break;
|
||||
// Blocklisted media should be silently ignored during watchlist sync to avoid spam
|
||||
case BlocklistedMediaError:
|
||||
// Blacklisted media should be silently ignored during watchlist sync to avoid spam
|
||||
case BlacklistedMediaError:
|
||||
break;
|
||||
default:
|
||||
logger.error('Failed to create media request from watchlist', {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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;
|
||||