Compare commits
70 Commits
renovate/n
...
v3.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
789bcc8eec | ||
|
|
8b0831cd9a | ||
|
|
92504b7864 | ||
|
|
9822565536 | ||
|
|
018e04a657 | ||
|
|
e503de323a | ||
|
|
bcd8002887 | ||
|
|
33a5d9a9ac | ||
|
|
15be3d7475 | ||
|
|
55c2d541e6 | ||
|
|
1ed86c14c0 | ||
|
|
91261f6a61 | ||
|
|
3dea58eead | ||
|
|
e842036faf | ||
|
|
2f07c38272 | ||
|
|
697a08167f | ||
|
|
017c69abb0 | ||
|
|
49ac9c92b3 | ||
|
|
dba98c0466 | ||
|
|
c187ac0965 | ||
|
|
01edc6c103 | ||
|
|
51ac65a78d | ||
|
|
28620e98d5 | ||
|
|
fac453878e | ||
|
|
528db09954 | ||
|
|
5663ac1af3 | ||
|
|
3b23da4ed7 | ||
|
|
4560c0f843 | ||
|
|
2cd843535d | ||
|
|
ceaf0b6df5 | ||
|
|
73c7ff257f | ||
|
|
0bb2ee0e84 | ||
|
|
4eab73ae9f | ||
|
|
7fb18d7b2c | ||
|
|
a27bdb8ec6 | ||
|
|
577288598a | ||
|
|
d4b707e619 | ||
|
|
8233d97f21 | ||
|
|
d362b030f9 | ||
|
|
cc876c8276 | ||
|
|
f2d7a21648 | ||
|
|
a7fe00d123 | ||
|
|
e43fc721c8 | ||
|
|
c53e465130 | ||
|
|
dc2cd9f28e | ||
|
|
dfa0229a6d | ||
|
|
63dfe003b0 | ||
|
|
a47db19ae7 | ||
|
|
65def9d20d | ||
|
|
a302929966 | ||
|
|
f735d86064 | ||
|
|
66c5de2bfa | ||
|
|
6cf1ac7295 | ||
|
|
25bf4b275a | ||
|
|
103f028d99 | ||
|
|
2101d0fff5 | ||
|
|
09f50ac80f | ||
|
|
24fde7aec2 | ||
|
|
d03bdf0cf9 | ||
|
|
12986990ae | ||
|
|
325e2ed6d3 | ||
|
|
e7c11da52b | ||
|
|
5712e19804 | ||
|
|
4b549763e5 | ||
|
|
24151d27f7 | ||
|
|
f3cc8cba0a | ||
|
|
57e7d68092 | ||
|
|
d3622f7bb3 | ||
|
|
20c821e2eb | ||
|
|
7b82ced5e6 |
2
.github/workflows/docs-deploy.yml
vendored
@@ -6,7 +6,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- legacy-jellyseerr
|
- develop
|
||||||
paths:
|
paths:
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- 'gen-docs/**'
|
- 'gen-docs/**'
|
||||||
|
|||||||
20
.github/workflows/release.yml
vendored
@@ -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
|
||||||
|
|||||||
1959
CHANGELOG.md
Normal file
36
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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!
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "seerr",
|
"name": "seerr",
|
||||||
"version": "0.1.0",
|
"version": "3.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@10.24.0",
|
"packageManager": "pnpm@10.24.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 821 B After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 193 KiB |
@@ -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';
|
||||||
|
|||||||
160
seerr-api.yml
@@ -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
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ export enum MediaStatus {
|
|||||||
PROCESSING,
|
PROCESSING,
|
||||||
PARTIALLY_AVAILABLE,
|
PARTIALLY_AVAILABLE,
|
||||||
AVAILABLE,
|
AVAILABLE,
|
||||||
BLACKLISTED,
|
BLOCKLISTED,
|
||||||
DELETED,
|
DELETED,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -38,65 +38,65 @@ export class Blacklist implements BlacklistItem {
|
|||||||
})
|
})
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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', {
|
||||||
|
|||||||
49
server/middleware/deprecation.ts
Normal 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;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class RenameBlacklistToBlocklist1771080196816 implements MigrationInterface {
|
||||||
|
name = 'RenameBlacklistToBlocklist1771080196816';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "blacklist" RENAME TO "blocklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blocklist" RENAME COLUMN "blacklistedTags" TO "blocklistedTags"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "blocklist" RENAME COLUMN "blocklistedTags" TO "blacklistedTags"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`ALTER TABLE "blocklist" RENAME TO "blacklist"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class RenameBlacklistToBlocklist1771080196816 implements MigrationInterface {
|
||||||
|
name = 'RenameBlacklistToBlocklist1771080196816';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "temporary_blocklist" (
|
||||||
|
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
"mediaType" varchar NOT NULL,
|
||||||
|
"title" varchar,
|
||||||
|
"tmdbId" integer NOT NULL,
|
||||||
|
"blocklistedTags" varchar,
|
||||||
|
"createdAt" datetime NOT NULL DEFAULT (datetime('now')),
|
||||||
|
"userId" integer,
|
||||||
|
"mediaId" integer,
|
||||||
|
CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"),
|
||||||
|
CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"),
|
||||||
|
CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION,
|
||||||
|
CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO "temporary_blocklist" ("id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId")
|
||||||
|
SELECT "id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId" FROM "blacklist"
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_blocklist" RENAME TO "blocklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blocklist" ("tmdbId")`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "blocklist" RENAME TO "blacklist"`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE "temporary_blacklist" (
|
||||||
|
"id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
"mediaType" varchar NOT NULL,
|
||||||
|
"title" varchar,
|
||||||
|
"tmdbId" integer NOT NULL,
|
||||||
|
"blacklistedTags" varchar,
|
||||||
|
"createdAt" datetime NOT NULL DEFAULT (datetime('now')),
|
||||||
|
"userId" integer,
|
||||||
|
"mediaId" integer,
|
||||||
|
CONSTRAINT "UQ_6bbafa28411e6046421991ea21c" UNIQUE ("tmdbId"),
|
||||||
|
CONSTRAINT "REL_62b7ade94540f9f8d8bede54b9" UNIQUE ("mediaId"),
|
||||||
|
CONSTRAINT "FK_53c1ab62c3e5875bc3ac474823e" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION,
|
||||||
|
CONSTRAINT "FK_62b7ade94540f9f8d8bede54b99" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO "temporary_blacklist" ("id", "mediaType", "title", "tmdbId", "blacklistedTags", "createdAt", "userId", "mediaId")
|
||||||
|
SELECT "id", "mediaType", "title", "tmdbId", "blocklistedTags", "createdAt", "userId", "mediaId" FROM "blacklist"
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`DROP TABLE "blacklist"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_blacklist" RENAME TO "blacklist"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX "IDX_6bbafa28411e6046421991ea21" ON "blacklist" ("tmdbId")`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { MediaType } from '@server/constants/media';
|
import { MediaType } from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } 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 { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
|
import type { BlocklistResultsResponse } from '@server/interfaces/api/blocklistInterfaces';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { isAuthenticated } from '@server/middleware/auth';
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
@@ -10,53 +10,53 @@ import { Router } from 'express';
|
|||||||
import { EntityNotFoundError, QueryFailedError } from 'typeorm';
|
import { EntityNotFoundError, QueryFailedError } from 'typeorm';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const blacklistRoutes = Router();
|
const blocklistRoutes = Router();
|
||||||
|
|
||||||
export const blacklistAdd = z.object({
|
export const blocklistAdd = z.object({
|
||||||
tmdbId: z.coerce.number(),
|
tmdbId: z.coerce.number(),
|
||||||
mediaType: z.nativeEnum(MediaType),
|
mediaType: z.nativeEnum(MediaType),
|
||||||
title: z.coerce.string().optional(),
|
title: z.coerce.string().optional(),
|
||||||
user: z.coerce.number(),
|
user: z.coerce.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const blacklistGet = z.object({
|
const blocklistGet = z.object({
|
||||||
take: z.coerce.number().int().positive().default(25),
|
take: z.coerce.number().int().positive().default(25),
|
||||||
skip: z.coerce.number().int().nonnegative().default(0),
|
skip: z.coerce.number().int().nonnegative().default(0),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
filter: z.enum(['all', 'manual', 'blacklistedTags']).optional(),
|
filter: z.enum(['all', 'manual', 'blocklistedTags']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
blacklistRoutes.get(
|
blocklistRoutes.get(
|
||||||
'/',
|
'/',
|
||||||
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
|
isAuthenticated([Permission.MANAGE_BLOCKLIST, Permission.VIEW_BLOCKLIST], {
|
||||||
type: 'or',
|
type: 'or',
|
||||||
}),
|
}),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
const { take, skip, search, filter } = blacklistGet.parse(req.query);
|
const { take, skip, search, filter } = blocklistGet.parse(req.query);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let query = getRepository(Blacklist)
|
let query = getRepository(Blocklist)
|
||||||
.createQueryBuilder('blacklist')
|
.createQueryBuilder('blocklist')
|
||||||
.leftJoinAndSelect('blacklist.user', 'user')
|
.leftJoinAndSelect('blocklist.user', 'user')
|
||||||
.where('1 = 1'); // Allow use of andWhere later
|
.where('1 = 1'); // Allow use of andWhere later
|
||||||
|
|
||||||
switch (filter) {
|
switch (filter) {
|
||||||
case 'manual':
|
case 'manual':
|
||||||
query = query.andWhere('blacklist.blacklistedTags IS NULL');
|
query = query.andWhere('blocklist.blocklistedTags IS NULL');
|
||||||
break;
|
break;
|
||||||
case 'blacklistedTags':
|
case 'blocklistedTags':
|
||||||
query = query.andWhere('blacklist.blacklistedTags IS NOT NULL');
|
query = query.andWhere('blocklist.blocklistedTags IS NOT NULL');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
query = query.andWhere('blacklist.title like :title', {
|
query = query.andWhere('blocklist.title like :title', {
|
||||||
title: `%${search}%`,
|
title: `%${search}%`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const [blacklistedItems, itemsCount] = await query
|
const [blocklistedItems, itemsCount] = await query
|
||||||
.orderBy('blacklist.createdAt', 'DESC')
|
.orderBy('blocklist.createdAt', 'DESC')
|
||||||
.take(take)
|
.take(take)
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.getManyAndCount();
|
.getManyAndCount();
|
||||||
@@ -68,35 +68,35 @@ blacklistRoutes.get(
|
|||||||
results: itemsCount,
|
results: itemsCount,
|
||||||
page: Math.ceil(skip / take) + 1,
|
page: Math.ceil(skip / take) + 1,
|
||||||
},
|
},
|
||||||
results: blacklistedItems,
|
results: blocklistedItems,
|
||||||
} as BlacklistResultsResponse);
|
} as BlocklistResultsResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Something went wrong while retrieving blacklisted items', {
|
logger.error('Something went wrong while retrieving blocklisted items', {
|
||||||
label: 'Blacklist',
|
label: 'Blocklist',
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
});
|
});
|
||||||
return next({
|
return next({
|
||||||
status: 500,
|
status: 500,
|
||||||
message: 'Unable to retrieve blacklisted items.',
|
message: 'Unable to retrieve blocklisted items.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
blacklistRoutes.get(
|
blocklistRoutes.get(
|
||||||
'/:id',
|
'/:id',
|
||||||
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
isAuthenticated([Permission.MANAGE_BLOCKLIST], {
|
||||||
type: 'or',
|
type: 'or',
|
||||||
}),
|
}),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const blacklisteRepository = getRepository(Blacklist);
|
const blocklisteRepository = getRepository(Blocklist);
|
||||||
|
|
||||||
const blacklistItem = await blacklisteRepository.findOneOrFail({
|
const blocklistItem = await blocklisteRepository.findOneOrFail({
|
||||||
where: { tmdbId: Number(req.params.id) },
|
where: { tmdbId: Number(req.params.id) },
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).send(blacklistItem);
|
return res.status(200).send(blocklistItem);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof EntityNotFoundError) {
|
if (e instanceof EntityNotFoundError) {
|
||||||
return next({
|
return next({
|
||||||
@@ -109,17 +109,17 @@ blacklistRoutes.get(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
blacklistRoutes.post(
|
blocklistRoutes.post(
|
||||||
'/',
|
'/',
|
||||||
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
isAuthenticated([Permission.MANAGE_BLOCKLIST], {
|
||||||
type: 'or',
|
type: 'or',
|
||||||
}),
|
}),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const values = blacklistAdd.parse(req.body);
|
const values = blocklistAdd.parse(req.body);
|
||||||
|
|
||||||
await Blacklist.addToBlacklist({
|
await Blocklist.addToBlocklist({
|
||||||
blacklistRequest: values,
|
blocklistRequest: values,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(201).send();
|
return res.status(201).send();
|
||||||
@@ -131,12 +131,12 @@ blacklistRoutes.post(
|
|||||||
if (error instanceof QueryFailedError) {
|
if (error instanceof QueryFailedError) {
|
||||||
switch (error.driverError.errno) {
|
switch (error.driverError.errno) {
|
||||||
case 19:
|
case 19:
|
||||||
return next({ status: 412, message: 'Item already blacklisted' });
|
return next({ status: 412, message: 'Item already blocklisted' });
|
||||||
default:
|
default:
|
||||||
logger.warn('Something wrong with data blacklist', {
|
logger.warn('Something wrong with data blocklist', {
|
||||||
tmdbId: req.body.tmdbId,
|
tmdbId: req.body.tmdbId,
|
||||||
mediaType: req.body.mediaType,
|
mediaType: req.body.mediaType,
|
||||||
label: 'Blacklist',
|
label: 'Blocklist',
|
||||||
});
|
});
|
||||||
return next({ status: 409, message: 'Something wrong' });
|
return next({ status: 409, message: 'Something wrong' });
|
||||||
}
|
}
|
||||||
@@ -147,20 +147,20 @@ blacklistRoutes.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
blacklistRoutes.delete(
|
blocklistRoutes.delete(
|
||||||
'/:id',
|
'/:id',
|
||||||
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
isAuthenticated([Permission.MANAGE_BLOCKLIST], {
|
||||||
type: 'or',
|
type: 'or',
|
||||||
}),
|
}),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const blacklisteRepository = getRepository(Blacklist);
|
const blocklisteRepository = getRepository(Blocklist);
|
||||||
|
|
||||||
const blacklistItem = await blacklisteRepository.findOneOrFail({
|
const blocklistItem = await blocklisteRepository.findOneOrFail({
|
||||||
where: { tmdbId: Number(req.params.id) },
|
where: { tmdbId: Number(req.params.id) },
|
||||||
});
|
});
|
||||||
|
|
||||||
await blacklisteRepository.remove(blacklistItem);
|
await blocklisteRepository.remove(blocklistItem);
|
||||||
|
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
|
|
||||||
@@ -183,4 +183,4 @@ blacklistRoutes.delete(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export default blacklistRoutes;
|
export default blocklistRoutes;
|
||||||
@@ -12,6 +12,7 @@ import { Permission } from '@server/lib/permissions';
|
|||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import logger from '@server/logger';
|
import logger from '@server/logger';
|
||||||
import { checkUser, isAuthenticated } from '@server/middleware/auth';
|
import { checkUser, isAuthenticated } from '@server/middleware/auth';
|
||||||
|
import deprecatedRoute from '@server/middleware/deprecation';
|
||||||
import { mapProductionCompany } from '@server/models/Movie';
|
import { mapProductionCompany } from '@server/models/Movie';
|
||||||
import { mapNetwork } from '@server/models/Tv';
|
import { mapNetwork } from '@server/models/Tv';
|
||||||
import { mapWatchProviderDetails } from '@server/models/common';
|
import { mapWatchProviderDetails } from '@server/models/common';
|
||||||
@@ -28,7 +29,7 @@ import restartFlag from '@server/utils/restartFlag';
|
|||||||
import { isPerson } from '@server/utils/typeHelpers';
|
import { isPerson } from '@server/utils/typeHelpers';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import authRoutes from './auth';
|
import authRoutes from './auth';
|
||||||
import blacklistRoutes from './blacklist';
|
import blocklistRoutes from './blocklist';
|
||||||
import collectionRoutes from './collection';
|
import collectionRoutes from './collection';
|
||||||
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
|
import discoverRoutes, { createTmdbWithRegionLanguage } from './discover';
|
||||||
import issueRoutes from './issue';
|
import issueRoutes from './issue';
|
||||||
@@ -151,7 +152,17 @@ router.use('/search', isAuthenticated(), searchRoutes);
|
|||||||
router.use('/discover', isAuthenticated(), discoverRoutes);
|
router.use('/discover', isAuthenticated(), discoverRoutes);
|
||||||
router.use('/request', isAuthenticated(), requestRoutes);
|
router.use('/request', isAuthenticated(), requestRoutes);
|
||||||
router.use('/watchlist', isAuthenticated(), watchlistRoutes);
|
router.use('/watchlist', isAuthenticated(), watchlistRoutes);
|
||||||
router.use('/blacklist', isAuthenticated(), blacklistRoutes);
|
router.use('/blocklist', isAuthenticated(), blocklistRoutes);
|
||||||
|
router.use(
|
||||||
|
'/blacklist',
|
||||||
|
isAuthenticated(),
|
||||||
|
deprecatedRoute({
|
||||||
|
oldPath: '/api/v1/blacklist',
|
||||||
|
newPath: '/api/v1/blocklist',
|
||||||
|
sunsetDate: '2026-06-01',
|
||||||
|
}),
|
||||||
|
blocklistRoutes
|
||||||
|
);
|
||||||
router.use('/movie', isAuthenticated(), movieRoutes);
|
router.use('/movie', isAuthenticated(), movieRoutes);
|
||||||
router.use('/tv', isAuthenticated(), tvRoutes);
|
router.use('/tv', isAuthenticated(), tvRoutes);
|
||||||
router.use('/media', isAuthenticated(), mediaRoutes);
|
router.use('/media', isAuthenticated(), mediaRoutes);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
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,
|
||||||
@@ -326,7 +326,7 @@ requestRoutes.post<never, MediaRequest, MediaRequestBody>(
|
|||||||
return next({ status: 409, message: error.message });
|
return next({ status: 409, message: error.message });
|
||||||
case NoSeasonsAvailableError:
|
case NoSeasonsAvailableError:
|
||||||
return next({ status: 202, message: error.message });
|
return next({ status: 202, message: error.message });
|
||||||
case BlacklistedMediaError:
|
case BlocklistedMediaError:
|
||||||
return next({ status: 403, message: error.message });
|
return next({ status: 403, message: error.message });
|
||||||
default:
|
default:
|
||||||
return next({ status: 500, message: error.message });
|
return next({ status: 500, message: error.message });
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||||
|
import Season from '@server/entity/Season';
|
||||||
import SeasonRequest from '@server/entity/SeasonRequest';
|
import SeasonRequest from '@server/entity/SeasonRequest';
|
||||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
@@ -27,7 +28,7 @@ import type {
|
|||||||
RemoveEvent,
|
RemoveEvent,
|
||||||
UpdateEvent,
|
UpdateEvent,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { EventSubscriber } from 'typeorm';
|
import { EventSubscriber, Not } from 'typeorm';
|
||||||
|
|
||||||
const sanitizeDisplayName = (displayName: string): string => {
|
const sanitizeDisplayName = (displayName: string): string => {
|
||||||
return displayName
|
return displayName
|
||||||
@@ -397,10 +398,23 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
|||||||
await mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
})
|
})
|
||||||
.catch(async () => {
|
.catch(async () => {
|
||||||
const requestRepository = getRepository(MediaRequest);
|
try {
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
entity.status = MediaRequestStatus.FAILED;
|
if (entity.status !== MediaRequestStatus.FAILED) {
|
||||||
requestRepository.save(entity);
|
entity.status = MediaRequestStatus.FAILED;
|
||||||
|
await requestRepository.save(entity);
|
||||||
|
}
|
||||||
|
} catch (saveError) {
|
||||||
|
logger.error('Failed to mark request as FAILED', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
errorMessage:
|
||||||
|
saveError instanceof Error
|
||||||
|
? saveError.message
|
||||||
|
: String(saveError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
'Something went wrong sending movie request to Radarr, marking status as FAILED',
|
||||||
@@ -503,7 +517,6 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
|||||||
|
|
||||||
const media = await mediaRepository.findOne({
|
const media = await mediaRepository.findOne({
|
||||||
where: { id: entity.media.id },
|
where: { id: entity.media.id },
|
||||||
relations: { requests: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!media) {
|
if (!media) {
|
||||||
@@ -690,7 +703,6 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
|||||||
// We grab media again here to make sure we have the latest version of it
|
// We grab media again here to make sure we have the latest version of it
|
||||||
const media = await mediaRepository.findOne({
|
const media = await mediaRepository.findOne({
|
||||||
where: { id: entity.media.id },
|
where: { id: entity.media.id },
|
||||||
relations: { requests: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!media) {
|
if (!media) {
|
||||||
@@ -707,10 +719,23 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
|||||||
await mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
})
|
})
|
||||||
.catch(async () => {
|
.catch(async () => {
|
||||||
const requestRepository = getRepository(MediaRequest);
|
try {
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
entity.status = MediaRequestStatus.FAILED;
|
if (entity.status !== MediaRequestStatus.FAILED) {
|
||||||
requestRepository.save(entity);
|
entity.status = MediaRequestStatus.FAILED;
|
||||||
|
await requestRepository.save(entity);
|
||||||
|
}
|
||||||
|
} catch (saveError) {
|
||||||
|
logger.error('Failed to mark request as FAILED', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: entity.id,
|
||||||
|
errorMessage:
|
||||||
|
saveError instanceof Error
|
||||||
|
? saveError.message
|
||||||
|
: String(saveError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
'Something went wrong sending series request to Sonarr, marking status as FAILED',
|
'Something went wrong sending series request to Sonarr, marking status as FAILED',
|
||||||
@@ -758,7 +783,6 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
|||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
const media = await mediaRepository.findOne({
|
const media = await mediaRepository.findOne({
|
||||||
where: { id: entity.media.id },
|
where: { id: entity.media.id },
|
||||||
relations: { requests: true },
|
|
||||||
});
|
});
|
||||||
if (!media) {
|
if (!media) {
|
||||||
logger.error('Media data not found', {
|
logger.error('Media data not found', {
|
||||||
@@ -768,26 +792,29 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const statusKey = entity.is4k ? 'status4k' : 'status';
|
||||||
const seasonRequestRepository = getRepository(SeasonRequest);
|
const seasonRequestRepository = getRepository(SeasonRequest);
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
entity.status === MediaRequestStatus.APPROVED &&
|
entity.status === MediaRequestStatus.APPROVED &&
|
||||||
// Do not update the status if the item is already partially available or available
|
// Do not update the status if the item is already partially available or available
|
||||||
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE &&
|
media[statusKey] !== MediaStatus.AVAILABLE &&
|
||||||
media[entity.is4k ? 'status4k' : 'status'] !==
|
media[statusKey] !== MediaStatus.PARTIALLY_AVAILABLE &&
|
||||||
MediaStatus.PARTIALLY_AVAILABLE &&
|
media[statusKey] !== MediaStatus.PROCESSING
|
||||||
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.PROCESSING
|
|
||||||
) {
|
) {
|
||||||
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.PROCESSING;
|
media[statusKey] = MediaStatus.PROCESSING;
|
||||||
mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
media.mediaType === MediaType.MOVIE &&
|
media.mediaType === MediaType.MOVIE &&
|
||||||
entity.status === MediaRequestStatus.DECLINED &&
|
entity.status === MediaRequestStatus.DECLINED &&
|
||||||
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED
|
media[statusKey] !== MediaStatus.DELETED
|
||||||
) {
|
) {
|
||||||
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
media[statusKey] = MediaStatus.UNKNOWN;
|
||||||
mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -799,14 +826,71 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
|||||||
if (
|
if (
|
||||||
media.mediaType === MediaType.TV &&
|
media.mediaType === MediaType.TV &&
|
||||||
entity.status === MediaRequestStatus.DECLINED &&
|
entity.status === MediaRequestStatus.DECLINED &&
|
||||||
media.requests.filter(
|
media[statusKey] === MediaStatus.PENDING
|
||||||
(request) => request.status === MediaRequestStatus.PENDING
|
|
||||||
).length === 0 &&
|
|
||||||
media[entity.is4k ? 'status4k' : 'status'] === MediaStatus.PENDING &&
|
|
||||||
media[entity.is4k ? 'status4k' : 'status'] !== MediaStatus.DELETED
|
|
||||||
) {
|
) {
|
||||||
media[entity.is4k ? 'status4k' : 'status'] = MediaStatus.UNKNOWN;
|
const pendingCount = await requestRepository.count({
|
||||||
mediaRepository.save(media);
|
where: {
|
||||||
|
media: { id: media.id },
|
||||||
|
status: MediaRequestStatus.PENDING,
|
||||||
|
is4k: entity.is4k,
|
||||||
|
id: Not(entity.id),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pendingCount === 0) {
|
||||||
|
// Re-fetch media without requests to avoid cascade issues
|
||||||
|
const freshMedia = await mediaRepository.findOne({
|
||||||
|
where: { id: media.id },
|
||||||
|
});
|
||||||
|
if (freshMedia) {
|
||||||
|
freshMedia[statusKey] = MediaStatus.UNKNOWN;
|
||||||
|
await mediaRepository.save(freshMedia);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset season statuses when a TV request is declined
|
||||||
|
if (
|
||||||
|
media.mediaType === MediaType.TV &&
|
||||||
|
entity.status === MediaRequestStatus.DECLINED
|
||||||
|
) {
|
||||||
|
const seasonRepository = getRepository(Season);
|
||||||
|
const actualSeasons = await seasonRepository.find({
|
||||||
|
where: { media: { id: media.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const seasonRequest of entity.seasons) {
|
||||||
|
seasonRequest.status = MediaRequestStatus.DECLINED;
|
||||||
|
await seasonRequestRepository.save(seasonRequest);
|
||||||
|
|
||||||
|
const season = actualSeasons.find(
|
||||||
|
(s) => s.seasonNumber === seasonRequest.seasonNumber
|
||||||
|
);
|
||||||
|
|
||||||
|
if (season && season[statusKey] === MediaStatus.PENDING) {
|
||||||
|
const otherActiveRequests = await requestRepository
|
||||||
|
.createQueryBuilder('request')
|
||||||
|
.leftJoinAndSelect('request.seasons', 'season')
|
||||||
|
.where('request.mediaId = :mediaId', { mediaId: media.id })
|
||||||
|
.andWhere('request.id != :requestId', { requestId: entity.id })
|
||||||
|
.andWhere('request.is4k = :is4k', { is4k: entity.is4k })
|
||||||
|
.andWhere('request.status NOT IN (:...statuses)', {
|
||||||
|
statuses: [
|
||||||
|
MediaRequestStatus.DECLINED,
|
||||||
|
MediaRequestStatus.COMPLETED,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.andWhere('season.seasonNumber = :seasonNumber', {
|
||||||
|
seasonNumber: season.seasonNumber,
|
||||||
|
})
|
||||||
|
.getCount();
|
||||||
|
|
||||||
|
if (otherActiveRequests === 0) {
|
||||||
|
season[statusKey] = MediaStatus.UNKNOWN;
|
||||||
|
await seasonRepository.save(season);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Approve child seasons if parent is approved
|
// Approve child seasons if parent is approved
|
||||||
@@ -830,54 +914,74 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface<MediaRe
|
|||||||
relations: { requests: true },
|
relations: { requests: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!fullMedia) return;
|
const needsStatusUpdate =
|
||||||
|
|
||||||
if (
|
|
||||||
!fullMedia.requests.some((request) => !request.is4k) &&
|
!fullMedia.requests.some((request) => !request.is4k) &&
|
||||||
fullMedia.status !== MediaStatus.AVAILABLE
|
fullMedia.status !== MediaStatus.AVAILABLE;
|
||||||
) {
|
|
||||||
fullMedia.status = MediaStatus.UNKNOWN;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
const needs4kStatusUpdate =
|
||||||
!fullMedia.requests.some((request) => request.is4k) &&
|
!fullMedia.requests.some((request) => request.is4k) &&
|
||||||
fullMedia.status4k !== MediaStatus.AVAILABLE
|
fullMedia.status4k !== MediaStatus.AVAILABLE;
|
||||||
) {
|
|
||||||
fullMedia.status4k = MediaStatus.UNKNOWN;
|
|
||||||
}
|
|
||||||
|
|
||||||
await manager.save(fullMedia);
|
if (needsStatusUpdate || needs4kStatusUpdate) {
|
||||||
|
// Re-fetch WITHOUT requests to avoid cascade issues on save
|
||||||
|
const cleanMedia = await manager.findOneOrFail(Media, {
|
||||||
|
where: { id: entity.media.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (needsStatusUpdate) {
|
||||||
|
cleanMedia.status = MediaStatus.UNKNOWN;
|
||||||
|
}
|
||||||
|
if (needs4kStatusUpdate) {
|
||||||
|
cleanMedia.status4k = MediaStatus.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.save(cleanMedia);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public afterUpdate(event: UpdateEvent<MediaRequest>): void {
|
public async afterUpdate(event: UpdateEvent<MediaRequest>): Promise<void> {
|
||||||
if (!event.entity) {
|
if (!event.entity) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendToRadarr(event.entity as MediaRequest);
|
try {
|
||||||
this.sendToSonarr(event.entity as MediaRequest);
|
await this.sendToRadarr(event.entity as MediaRequest);
|
||||||
|
await this.sendToSonarr(event.entity as MediaRequest);
|
||||||
|
await this.updateParentStatus(event.entity as MediaRequest);
|
||||||
|
|
||||||
this.updateParentStatus(event.entity as MediaRequest);
|
if (event.entity.status === MediaRequestStatus.COMPLETED) {
|
||||||
|
if (event.entity.media.mediaType === MediaType.MOVIE) {
|
||||||
if (event.entity.status === MediaRequestStatus.COMPLETED) {
|
await this.notifyAvailableMovie(event.entity as MediaRequest, event);
|
||||||
if (event.entity.media.mediaType === MediaType.MOVIE) {
|
}
|
||||||
this.notifyAvailableMovie(event.entity as MediaRequest, event);
|
if (event.entity.media.mediaType === MediaType.TV) {
|
||||||
}
|
await this.notifyAvailableSeries(event.entity as MediaRequest, event);
|
||||||
if (event.entity.media.mediaType === MediaType.TV) {
|
}
|
||||||
this.notifyAvailableSeries(event.entity as MediaRequest, event);
|
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Error in afterUpdate subscriber', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: (event.entity as MediaRequest).id,
|
||||||
|
errorMessage: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public afterInsert(event: InsertEvent<MediaRequest>): void {
|
public async afterInsert(event: InsertEvent<MediaRequest>): Promise<void> {
|
||||||
if (!event.entity) {
|
if (!event.entity) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendToRadarr(event.entity as MediaRequest);
|
try {
|
||||||
this.sendToSonarr(event.entity as MediaRequest);
|
await this.sendToRadarr(event.entity as MediaRequest);
|
||||||
|
await this.sendToSonarr(event.entity as MediaRequest);
|
||||||
this.updateParentStatus(event.entity as MediaRequest);
|
await this.updateParentStatus(event.entity as MediaRequest);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Error in afterInsert subscriber', {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: (event.entity as MediaRequest).id,
|
||||||
|
errorMessage: e instanceof Error ? e.message : String(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async afterRemove(event: RemoveEvent<MediaRequest>): Promise<void> {
|
public async afterRemove(event: RemoveEvent<MediaRequest>): Promise<void> {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import BlacklistedTagsBadge from '@app/components/BlacklistedTagsBadge';
|
import BlocklistedTagsBadge from '@app/components/BlocklistedTagsBadge';
|
||||||
import Badge from '@app/components/Common/Badge';
|
import Badge from '@app/components/Common/Badge';
|
||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import CachedImage from '@app/components/Common/CachedImage';
|
import CachedImage from '@app/components/Common/CachedImage';
|
||||||
@@ -20,9 +20,9 @@ import {
|
|||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from '@heroicons/react/24/solid';
|
} from '@heroicons/react/24/solid';
|
||||||
import type {
|
import type {
|
||||||
BlacklistItem,
|
BlocklistItem,
|
||||||
BlacklistResultsResponse,
|
BlocklistResultsResponse,
|
||||||
} from '@server/interfaces/api/blacklistInterfaces';
|
} from '@server/interfaces/api/blocklistInterfaces';
|
||||||
import type { MovieDetails } from '@server/models/Movie';
|
import type { MovieDetails } from '@server/models/Movie';
|
||||||
import type { TvDetails } from '@server/models/Tv';
|
import type { TvDetails } from '@server/models/Tv';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -35,31 +35,31 @@ import { FormattedRelativeTime, useIntl } from 'react-intl';
|
|||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages('components.Blacklist', {
|
const messages = defineMessages('components.Blocklist', {
|
||||||
blacklistsettings: 'Blacklist Settings',
|
blocklistsettings: 'Blocklist Settings',
|
||||||
blacklistSettingsDescription: 'Manage blacklisted media.',
|
blocklistSettingsDescription: 'Manage blocklisted media.',
|
||||||
mediaName: 'Name',
|
mediaName: 'Name',
|
||||||
mediaType: 'Type',
|
mediaType: 'Type',
|
||||||
mediaTmdbId: 'tmdb Id',
|
mediaTmdbId: 'tmdb Id',
|
||||||
blacklistdate: 'date',
|
blocklistdate: 'date',
|
||||||
blacklistedby: '{date} by {user}',
|
blocklistedby: '{date} by {user}',
|
||||||
blacklistNotFoundError: '<strong>{title}</strong> is not blacklisted.',
|
blocklistNotFoundError: '<strong>{title}</strong> is not blocklisted.',
|
||||||
filterManual: 'Manual',
|
filterManual: 'Manual',
|
||||||
filterBlacklistedTags: 'Blacklisted Tags',
|
filterBlocklistedTags: 'Blocklisted Tags',
|
||||||
showAllBlacklisted: 'Show All Blacklisted Media',
|
showAllBlocklisted: 'Show All Blocklisted Media',
|
||||||
});
|
});
|
||||||
|
|
||||||
enum Filter {
|
enum Filter {
|
||||||
ALL = 'all',
|
ALL = 'all',
|
||||||
MANUAL = 'manual',
|
MANUAL = 'manual',
|
||||||
BLACKLISTEDTAGS = 'blacklistedTags',
|
BLOCKLISTEDTAGS = 'blocklistedTags',
|
||||||
}
|
}
|
||||||
|
|
||||||
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
|
||||||
return (movie as MovieDetails).title !== undefined;
|
return (movie as MovieDetails).title !== undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Blacklist = () => {
|
const Blocklist = () => {
|
||||||
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
const [currentPageSize, setCurrentPageSize] = useState<number>(10);
|
||||||
const [searchFilter, debouncedSearchFilter, setSearchFilter] =
|
const [searchFilter, debouncedSearchFilter, setSearchFilter] =
|
||||||
useDebouncedState('');
|
useDebouncedState('');
|
||||||
@@ -75,8 +75,8 @@ const Blacklist = () => {
|
|||||||
data,
|
data,
|
||||||
error,
|
error,
|
||||||
mutate: revalidate,
|
mutate: revalidate,
|
||||||
} = useSWR<BlacklistResultsResponse>(
|
} = useSWR<BlocklistResultsResponse>(
|
||||||
`/api/v1/blacklist/?take=${currentPageSize}&skip=${
|
`/api/v1/blocklist/?take=${currentPageSize}&skip=${
|
||||||
pageIndex * currentPageSize
|
pageIndex * currentPageSize
|
||||||
}&filter=${currentFilter}${
|
}&filter=${currentFilter}${
|
||||||
debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''
|
debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''
|
||||||
@@ -107,9 +107,9 @@ const Blacklist = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle title={[intl.formatMessage(globalMessages.blacklist)]} />
|
<PageTitle title={[intl.formatMessage(globalMessages.blocklist)]} />
|
||||||
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
|
<div className="mb-4 flex flex-col justify-between lg:flex-row lg:items-end">
|
||||||
<Header>{intl.formatMessage(globalMessages.blacklist)}</Header>
|
<Header>{intl.formatMessage(globalMessages.blocklist)}</Header>
|
||||||
|
|
||||||
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
<div className="mt-2 flex flex-grow flex-col sm:flex-row lg:flex-grow-0">
|
||||||
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
<div className="mb-2 flex flex-grow sm:mb-0 sm:mr-2 lg:flex-grow-0">
|
||||||
@@ -137,8 +137,8 @@ const Blacklist = () => {
|
|||||||
<option value="manual">
|
<option value="manual">
|
||||||
{intl.formatMessage(messages.filterManual)}
|
{intl.formatMessage(messages.filterManual)}
|
||||||
</option>
|
</option>
|
||||||
<option value="blacklistedTags">
|
<option value="blocklistedTags">
|
||||||
{intl.formatMessage(messages.filterBlacklistedTags)}
|
{intl.formatMessage(messages.filterBlocklistedTags)}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,16 +170,16 @@ const Blacklist = () => {
|
|||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
onClick={() => setCurrentFilter(Filter.ALL)}
|
onClick={() => setCurrentFilter(Filter.ALL)}
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.showAllBlacklisted)}
|
{intl.formatMessage(messages.showAllBlocklisted)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
data.results.map((item: BlacklistItem) => {
|
data.results.map((item: BlocklistItem) => {
|
||||||
return (
|
return (
|
||||||
<div className="py-2" key={`request-list-${item.tmdbId}`}>
|
<div className="py-2" key={`request-list-${item.tmdbId}`}>
|
||||||
<BlacklistedItem item={item} revalidateList={revalidate} />
|
<BlocklistedItem item={item} revalidateList={revalidate} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -260,14 +260,14 @@ const Blacklist = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Blacklist;
|
export default Blocklist;
|
||||||
|
|
||||||
interface BlacklistedItemProps {
|
interface BlocklistedItemProps {
|
||||||
item: BlacklistItem;
|
item: BlocklistItem;
|
||||||
revalidateList: () => void;
|
revalidateList: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
const BlocklistedItem = ({ item, revalidateList }: BlocklistedItemProps) => {
|
||||||
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
const [isUpdating, setIsUpdating] = useState<boolean>(false);
|
||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
const { ref, inView } = useInView({
|
const { ref, inView } = useInView({
|
||||||
@@ -293,15 +293,15 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
|
const removeFromBlocklist = async (tmdbId: number, title?: string) => {
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/v1/blacklist/${tmdbId}`);
|
await axios.delete(`/api/v1/blocklist/${tmdbId}`);
|
||||||
|
|
||||||
addToast(
|
addToast(
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
|
{intl.formatMessage(globalMessages.removeFromBlocklistSuccess, {
|
||||||
title,
|
title,
|
||||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||||
})}
|
})}
|
||||||
@@ -309,7 +309,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
{ appearance: 'success', autoDismiss: true }
|
{ appearance: 'success', autoDismiss: true }
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
addToast(intl.formatMessage(globalMessages.blacklistError), {
|
addToast(intl.formatMessage(globalMessages.blocklistError), {
|
||||||
appearance: 'error',
|
appearance: 'error',
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
});
|
});
|
||||||
@@ -389,17 +389,17 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
<div className="card-field">
|
<div className="card-field">
|
||||||
<span className="card-field-name">Status</span>
|
<span className="card-field-name">Status</span>
|
||||||
<Badge badgeType="danger">
|
<Badge badgeType="danger">
|
||||||
{intl.formatMessage(globalMessages.blacklisted)}
|
{intl.formatMessage(globalMessages.blocklisted)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{item.createdAt && (
|
{item.createdAt && (
|
||||||
<div className="card-field">
|
<div className="card-field">
|
||||||
<span className="card-field-name">
|
<span className="card-field-name">
|
||||||
{intl.formatMessage(globalMessages.blacklisted)}
|
{intl.formatMessage(globalMessages.blocklisted)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex truncate text-sm text-gray-300">
|
<span className="flex truncate text-sm text-gray-300">
|
||||||
{intl.formatMessage(messages.blacklistedby, {
|
{intl.formatMessage(messages.blocklistedby, {
|
||||||
date: (
|
date: (
|
||||||
<FormattedRelativeTime
|
<FormattedRelativeTime
|
||||||
value={Math.floor(
|
value={Math.floor(
|
||||||
@@ -426,9 +426,9 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
) : item.blacklistedTags ? (
|
) : item.blocklistedTags ? (
|
||||||
<span className="ml-1">
|
<span className="ml-1">
|
||||||
<BlacklistedTagsBadge data={item} />
|
<BlocklistedTagsBadge data={item} />
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="ml-1 truncate text-sm font-semibold">
|
<span className="ml-1 truncate text-sm font-semibold">
|
||||||
@@ -457,10 +457,10 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
|
<div className="z-10 mt-4 flex w-full flex-col justify-center space-y-2 pl-4 pr-4 xl:mt-0 xl:w-96 xl:items-end xl:pl-0">
|
||||||
{hasPermission(Permission.MANAGE_BLACKLIST) && (
|
{hasPermission(Permission.MANAGE_BLOCKLIST) && (
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
removeFromBlacklist(
|
removeFromBlocklist(
|
||||||
item.tmdbId,
|
item.tmdbId,
|
||||||
title && (isMovie(title) ? title.title : title.name)
|
title && (isMovie(title) ? title.title : title.name)
|
||||||
)
|
)
|
||||||
@@ -474,7 +474,7 @@ const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
|||||||
>
|
>
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(globalMessages.removefromBlacklist)}
|
{intl.formatMessage(globalMessages.removefromBlocklist)}
|
||||||
</span>
|
</span>
|
||||||
</ConfirmButton>
|
</ConfirmButton>
|
||||||
)}
|
)}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import BlacklistedTagsBadge from '@app/components/BlacklistedTagsBadge';
|
import BlocklistedTagsBadge from '@app/components/BlocklistedTagsBadge';
|
||||||
import Badge from '@app/components/Common/Badge';
|
import Badge from '@app/components/Common/Badge';
|
||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||||
@@ -7,7 +7,7 @@ import { useUser } from '@app/hooks/useUser';
|
|||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { CalendarIcon, TrashIcon, UserIcon } from '@heroicons/react/24/solid';
|
import { CalendarIcon, TrashIcon, UserIcon } from '@heroicons/react/24/solid';
|
||||||
import type { Blacklist } from '@server/entity/Blacklist';
|
import type { Blocklist } from '@server/entity/Blocklist';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -15,37 +15,37 @@ import { useIntl } from 'react-intl';
|
|||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages('component.BlacklistBlock', {
|
const messages = defineMessages('component.BlocklistBlock', {
|
||||||
blacklistedby: 'Blacklisted By',
|
blocklistedby: 'Blocklisted By',
|
||||||
blacklistdate: 'Blacklisted date',
|
blocklistdate: 'Blocklisted date',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface BlacklistBlockProps {
|
interface BlocklistBlockProps {
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
onUpdate?: () => void;
|
onUpdate?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BlacklistBlock = ({
|
const BlocklistBlock = ({
|
||||||
tmdbId,
|
tmdbId,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: BlacklistBlockProps) => {
|
}: BlocklistBlockProps) => {
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
const { data } = useSWR<Blacklist>(`/api/v1/blacklist/${tmdbId}`);
|
const { data } = useSWR<Blocklist>(`/api/v1/blocklist/${tmdbId}`);
|
||||||
|
|
||||||
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
|
const removeFromBlocklist = async (tmdbId: number, title?: string) => {
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.delete('/api/v1/blacklist/' + tmdbId);
|
await axios.delete('/api/v1/blocklist/' + tmdbId);
|
||||||
|
|
||||||
addToast(
|
addToast(
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
|
{intl.formatMessage(globalMessages.removeFromBlocklistSuccess, {
|
||||||
title,
|
title,
|
||||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||||
})}
|
})}
|
||||||
@@ -53,7 +53,7 @@ const BlacklistBlock = ({
|
|||||||
{ appearance: 'success', autoDismiss: true }
|
{ appearance: 'success', autoDismiss: true }
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
addToast(intl.formatMessage(globalMessages.blacklistError), {
|
addToast(intl.formatMessage(globalMessages.blocklistError), {
|
||||||
appearance: 'error',
|
appearance: 'error',
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
});
|
});
|
||||||
@@ -80,7 +80,7 @@ const BlacklistBlock = ({
|
|||||||
<div className="white mb-1 flex flex-nowrap">
|
<div className="white mb-1 flex flex-nowrap">
|
||||||
{data.user ? (
|
{data.user ? (
|
||||||
<>
|
<>
|
||||||
<Tooltip content={intl.formatMessage(messages.blacklistedby)}>
|
<Tooltip content={intl.formatMessage(messages.blocklistedby)}>
|
||||||
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
|
<UserIcon className="mr-1.5 h-5 w-5 min-w-0 flex-shrink-0" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<span className="w-40 truncate md:w-auto">
|
<span className="w-40 truncate md:w-auto">
|
||||||
@@ -97,23 +97,23 @@ const BlacklistBlock = ({
|
|||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : data.blacklistedTags ? (
|
) : data.blocklistedTags ? (
|
||||||
<>
|
<>
|
||||||
<span className="w-40 truncate md:w-auto">
|
<span className="w-40 truncate md:w-auto">
|
||||||
{intl.formatMessage(messages.blacklistedby)}:
|
{intl.formatMessage(messages.blocklistedby)}:
|
||||||
</span>
|
</span>
|
||||||
<BlacklistedTagsBadge data={data} />
|
<BlocklistedTagsBadge data={data} />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-2 flex flex-shrink-0 flex-wrap">
|
<div className="ml-2 flex flex-shrink-0 flex-wrap">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={intl.formatMessage(globalMessages.removefromBlacklist)}
|
content={intl.formatMessage(globalMessages.removefromBlocklist)}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
buttonType="danger"
|
buttonType="danger"
|
||||||
onClick={() => removeFromBlacklist(data.tmdbId, data.title)}
|
onClick={() => removeFromBlocklist(data.tmdbId, data.title)}
|
||||||
disabled={isUpdating}
|
disabled={isUpdating}
|
||||||
>
|
>
|
||||||
<TrashIcon className="icon-sm" />
|
<TrashIcon className="icon-sm" />
|
||||||
@@ -125,12 +125,12 @@ const BlacklistBlock = ({
|
|||||||
<div className="sm:flex">
|
<div className="sm:flex">
|
||||||
<div className="mr-6 flex items-center text-sm leading-5">
|
<div className="mr-6 flex items-center text-sm leading-5">
|
||||||
<Badge badgeType="danger">
|
<Badge badgeType="danger">
|
||||||
{intl.formatMessage(globalMessages.blacklisted)}
|
{intl.formatMessage(globalMessages.blocklisted)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center text-sm leading-5 sm:mt-0">
|
<div className="mt-2 flex items-center text-sm leading-5 sm:mt-0">
|
||||||
<Tooltip content={intl.formatMessage(messages.blacklistdate)}>
|
<Tooltip content={intl.formatMessage(messages.blocklistdate)}>
|
||||||
<CalendarIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
|
<CalendarIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<span>
|
<span>
|
||||||
@@ -146,4 +146,4 @@ const BlacklistBlock = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default BlacklistBlock;
|
export default BlocklistBlock;
|
||||||
@@ -8,7 +8,7 @@ import axios from 'axios';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
interface BlacklistModalProps {
|
interface BlocklistModalProps {
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
type: 'movie' | 'tv' | 'collection';
|
type: 'movie' | 'tv' | 'collection';
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -17,8 +17,8 @@ interface BlacklistModalProps {
|
|||||||
isUpdating?: boolean;
|
isUpdating?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = defineMessages('component.BlacklistModal', {
|
const messages = defineMessages('component.BlocklistModal', {
|
||||||
blacklisting: 'Blacklisting',
|
blocklisting: 'Blocklisting',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMovie = (
|
const isMovie = (
|
||||||
@@ -28,14 +28,14 @@ const isMovie = (
|
|||||||
return (movie as MovieDetails).title !== undefined;
|
return (movie as MovieDetails).title !== undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BlacklistModal = ({
|
const BlocklistModal = ({
|
||||||
tmdbId,
|
tmdbId,
|
||||||
type,
|
type,
|
||||||
show,
|
show,
|
||||||
onComplete,
|
onComplete,
|
||||||
onCancel,
|
onCancel,
|
||||||
isUpdating,
|
isUpdating,
|
||||||
}: BlacklistModalProps) => {
|
}: BlocklistModalProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [data, setData] = useState<TvDetails | MovieDetails | null>(null);
|
const [data, setData] = useState<TvDetails | MovieDetails | null>(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -67,7 +67,7 @@ const BlacklistModal = ({
|
|||||||
<Modal
|
<Modal
|
||||||
loading={!data && !error}
|
loading={!data && !error}
|
||||||
backgroundClickable
|
backgroundClickable
|
||||||
title={`${intl.formatMessage(globalMessages.blacklist)} ${
|
title={`${intl.formatMessage(globalMessages.blocklist)} ${
|
||||||
isMovie(data)
|
isMovie(data)
|
||||||
? intl.formatMessage(globalMessages.movie)
|
? intl.formatMessage(globalMessages.movie)
|
||||||
: intl.formatMessage(globalMessages.tvshow)
|
: intl.formatMessage(globalMessages.tvshow)
|
||||||
@@ -77,8 +77,8 @@ const BlacklistModal = ({
|
|||||||
onOk={onComplete}
|
onOk={onComplete}
|
||||||
okText={
|
okText={
|
||||||
isUpdating
|
isUpdating
|
||||||
? intl.formatMessage(messages.blacklisting)
|
? intl.formatMessage(messages.blocklisting)
|
||||||
: intl.formatMessage(globalMessages.blacklist)
|
: intl.formatMessage(globalMessages.blocklist)
|
||||||
}
|
}
|
||||||
okButtonType="danger"
|
okButtonType="danger"
|
||||||
okDisabled={isUpdating}
|
okDisabled={isUpdating}
|
||||||
@@ -88,4 +88,4 @@ const BlacklistModal = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default BlacklistModal;
|
export default BlocklistModal;
|
||||||
@@ -2,31 +2,31 @@ import Badge from '@app/components/Common/Badge';
|
|||||||
import Tooltip from '@app/components/Common/Tooltip';
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
import defineMessages from '@app/utils/defineMessages';
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
import { TagIcon } from '@heroicons/react/20/solid';
|
import { TagIcon } from '@heroicons/react/20/solid';
|
||||||
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
|
import type { BlocklistItem } from '@server/interfaces/api/blocklistInterfaces';
|
||||||
import type { Keyword } from '@server/models/common';
|
import type { Keyword } from '@server/models/common';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages('components.Settings', {
|
const messages = defineMessages('components.Settings', {
|
||||||
blacklistedTagsText: 'Blacklisted Tags',
|
blocklistedTagsText: 'Blocklisted Tags',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface BlacklistedTagsBadgeProps {
|
interface BlocklistedTagsBadgeProps {
|
||||||
data: BlacklistItem;
|
data: BlocklistItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BlacklistedTagsBadge = ({ data }: BlacklistedTagsBadgeProps) => {
|
const BlocklistedTagsBadge = ({ data }: BlocklistedTagsBadgeProps) => {
|
||||||
const [tagNamesBlacklistedFor, setTagNamesBlacklistedFor] =
|
const [tagNamesBlocklistedFor, setTagNamesBlocklistedFor] =
|
||||||
useState<string>('Loading...');
|
useState<string>('Loading...');
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data.blacklistedTags) {
|
if (!data.blocklistedTags) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keywordIds = data.blacklistedTags.slice(1, -1).split(',');
|
const keywordIds = data.blocklistedTags.slice(1, -1).split(',');
|
||||||
Promise.all(
|
Promise.all(
|
||||||
keywordIds.map(async (keywordId) => {
|
keywordIds.map(async (keywordId) => {
|
||||||
const { data } = await axios.get<Keyword | null>(
|
const { data } = await axios.get<Keyword | null>(
|
||||||
@@ -35,13 +35,13 @@ const BlacklistedTagsBadge = ({ data }: BlacklistedTagsBadgeProps) => {
|
|||||||
return data?.name || `[Invalid: ${keywordId}]`;
|
return data?.name || `[Invalid: ${keywordId}]`;
|
||||||
})
|
})
|
||||||
).then((keywords) => {
|
).then((keywords) => {
|
||||||
setTagNamesBlacklistedFor(keywords.join(', '));
|
setTagNamesBlocklistedFor(keywords.join(', '));
|
||||||
});
|
});
|
||||||
}, [data.blacklistedTags]);
|
}, [data.blocklistedTags]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={tagNamesBlacklistedFor}
|
content={tagNamesBlocklistedFor}
|
||||||
tooltipConfig={{ followCursor: false }}
|
tooltipConfig={{ followCursor: false }}
|
||||||
>
|
>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -49,10 +49,10 @@ const BlacklistedTagsBadge = ({ data }: BlacklistedTagsBadgeProps) => {
|
|||||||
className="items-center border border-red-500 !text-red-400"
|
className="items-center border border-red-500 !text-red-400"
|
||||||
>
|
>
|
||||||
<TagIcon className="mr-1 h-4" />
|
<TagIcon className="mr-1 h-4" />
|
||||||
{intl.formatMessage(messages.blacklistedTagsText)}
|
{intl.formatMessage(messages.blocklistedTagsText)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default BlacklistedTagsBadge;
|
export default BlocklistedTagsBadge;
|
||||||
@@ -26,19 +26,19 @@ import { components } from 'react-select';
|
|||||||
import AsyncSelect from 'react-select/async';
|
import AsyncSelect from 'react-select/async';
|
||||||
|
|
||||||
const messages = defineMessages('components.Settings', {
|
const messages = defineMessages('components.Settings', {
|
||||||
copyBlacklistedTags: 'Copied blacklisted tags to clipboard.',
|
copyBlocklistedTags: 'Copied blocklisted tags to clipboard.',
|
||||||
copyBlacklistedTagsTip: 'Copy blacklisted tag configuration',
|
copyBlocklistedTagsTip: 'Copy blocklisted tag configuration',
|
||||||
copyBlacklistedTagsEmpty: 'Nothing to copy',
|
copyBlocklistedTagsEmpty: 'Nothing to copy',
|
||||||
importBlacklistedTagsTip: 'Import blacklisted tag configuration',
|
importBlocklistedTagsTip: 'Import blocklisted tag configuration',
|
||||||
clearBlacklistedTagsConfirm:
|
clearBlocklistedTagsConfirm:
|
||||||
'Are you sure you want to clear the blacklisted tags?',
|
'Are you sure you want to clear the blocklisted tags?',
|
||||||
yes: 'Yes',
|
yes: 'Yes',
|
||||||
no: 'No',
|
no: 'No',
|
||||||
searchKeywords: 'Search keywords…',
|
searchKeywords: 'Search keywords…',
|
||||||
starttyping: 'Starting typing to search.',
|
starttyping: 'Starting typing to search.',
|
||||||
nooptions: 'No results.',
|
nooptions: 'No results.',
|
||||||
blacklistedTagImportTitle: 'Import Blacklisted Tag Configuration',
|
blocklistedTagImportTitle: 'Import Blocklisted Tag Configuration',
|
||||||
blacklistedTagImportInstructions: 'Paste blacklist tag configuration below.',
|
blocklistedTagImportInstructions: 'Paste blocklist tag configuration below.',
|
||||||
valueRequired: 'You must provide a value.',
|
valueRequired: 'You must provide a value.',
|
||||||
noSpecialCharacters:
|
noSpecialCharacters:
|
||||||
'Configuration must be a comma delimited list of TMDB keyword ids, and must not start or end with a comma.',
|
'Configuration must be a comma delimited list of TMDB keyword ids, and must not start or end with a comma.',
|
||||||
@@ -50,13 +50,13 @@ type SingleVal = {
|
|||||||
value: number;
|
value: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BlacklistedTagsSelectorProps = {
|
type BlocklistedTagsSelectorProps = {
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BlacklistedTagsSelector = ({
|
const BlocklistedTagsSelector = ({
|
||||||
defaultValue,
|
defaultValue,
|
||||||
}: BlacklistedTagsSelectorProps) => {
|
}: BlocklistedTagsSelectorProps) => {
|
||||||
const { setFieldValue } = useFormikContext();
|
const { setFieldValue } = useFormikContext();
|
||||||
const [value, setValue] = useState<string | undefined>(defaultValue);
|
const [value, setValue] = useState<string | undefined>(defaultValue);
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -68,7 +68,7 @@ const BlacklistedTagsSelector = ({
|
|||||||
const strVal = value?.map((v) => v.value).join(',');
|
const strVal = value?.map((v) => v.value).join(',');
|
||||||
setSelectorValue(value);
|
setSelectorValue(value);
|
||||||
setValue(strVal);
|
setValue(strVal);
|
||||||
setFieldValue('blacklistedTags', strVal);
|
setFieldValue('blocklistedTags', strVal);
|
||||||
},
|
},
|
||||||
[setSelectorValue, setValue, setFieldValue]
|
[setSelectorValue, setValue, setFieldValue]
|
||||||
);
|
);
|
||||||
@@ -91,15 +91,15 @@ const BlacklistedTagsSelector = ({
|
|||||||
<CopyButton
|
<CopyButton
|
||||||
textToCopy={value ?? ''}
|
textToCopy={value ?? ''}
|
||||||
disabled={copyDisabled}
|
disabled={copyDisabled}
|
||||||
toastMessage={intl.formatMessage(messages.copyBlacklistedTags)}
|
toastMessage={intl.formatMessage(messages.copyBlocklistedTags)}
|
||||||
tooltipContent={intl.formatMessage(
|
tooltipContent={intl.formatMessage(
|
||||||
copyDisabled
|
copyDisabled
|
||||||
? messages.copyBlacklistedTagsEmpty
|
? messages.copyBlocklistedTagsEmpty
|
||||||
: messages.copyBlacklistedTagsTip
|
: messages.copyBlocklistedTagsTip
|
||||||
)}
|
)}
|
||||||
tooltipConfig={{ followCursor: false }}
|
tooltipConfig={{ followCursor: false }}
|
||||||
/>
|
/>
|
||||||
<BlacklistedTagsImportButton setSelector={update} />
|
<BlocklistedTagsImportButton setSelector={update} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -162,7 +162,7 @@ const ControlledKeywordSelector = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AsyncSelect
|
<AsyncSelect
|
||||||
key={`keyword-select-blacklistedTags`}
|
key={`keyword-select-blocklistedTags`}
|
||||||
inputId="data"
|
inputId="data"
|
||||||
isMulti
|
isMulti
|
||||||
className="react-select-container"
|
className="react-select-container"
|
||||||
@@ -181,13 +181,13 @@ const ControlledKeywordSelector = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type BlacklistedTagsImportButtonProps = {
|
type BlocklistedTagsImportButtonProps = {
|
||||||
setSelector: (value: MultiValue<SingleVal>) => void;
|
setSelector: (value: MultiValue<SingleVal>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BlacklistedTagsImportButton = ({
|
const BlocklistedTagsImportButton = ({
|
||||||
setSelector,
|
setSelector,
|
||||||
}: BlacklistedTagsImportButtonProps) => {
|
}: BlocklistedTagsImportButtonProps) => {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
const formRef = useRef<HTMLFormElement>(null);
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -218,17 +218,17 @@ const BlacklistedTagsImportButton = ({
|
|||||||
show={show}
|
show={show}
|
||||||
>
|
>
|
||||||
<Modal
|
<Modal
|
||||||
title={intl.formatMessage(messages.blacklistedTagImportTitle)}
|
title={intl.formatMessage(messages.blocklistedTagImportTitle)}
|
||||||
okText="Confirm"
|
okText="Confirm"
|
||||||
onOk={onConfirm}
|
onOk={onConfirm}
|
||||||
onCancel={() => setShow(false)}
|
onCancel={() => setShow(false)}
|
||||||
>
|
>
|
||||||
<BlacklistedTagImportForm ref={formRef} setSelector={setSelector} />
|
<BlocklistedTagImportForm ref={formRef} setSelector={setSelector} />
|
||||||
</Modal>
|
</Modal>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={intl.formatMessage(messages.importBlacklistedTagsTip)}
|
content={intl.formatMessage(messages.importBlocklistedTagsTip)}
|
||||||
tooltipConfig={{ followCursor: false }}
|
tooltipConfig={{ followCursor: false }}
|
||||||
>
|
>
|
||||||
<button className="input-action" onClick={onClick} type="button">
|
<button className="input-action" onClick={onClick} type="button">
|
||||||
@@ -239,11 +239,11 @@ const BlacklistedTagsImportButton = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type BlacklistedTagImportFormProps = BlacklistedTagsImportButtonProps;
|
type BlocklistedTagImportFormProps = BlocklistedTagsImportButtonProps;
|
||||||
|
|
||||||
const BlacklistedTagImportForm = forwardRef<
|
const BlocklistedTagImportForm = forwardRef<
|
||||||
Partial<HTMLFormElement>,
|
Partial<HTMLFormElement>,
|
||||||
BlacklistedTagImportFormProps
|
BlocklistedTagImportFormProps
|
||||||
>((props, ref) => {
|
>((props, ref) => {
|
||||||
const { setSelector } = props;
|
const { setSelector } = props;
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -306,7 +306,7 @@ const BlacklistedTagImportForm = forwardRef<
|
|||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="value">
|
<label htmlFor="value">
|
||||||
{intl.formatMessage(messages.blacklistedTagImportInstructions)}
|
{intl.formatMessage(messages.blocklistedTagImportInstructions)}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="value"
|
id="value"
|
||||||
@@ -392,7 +392,7 @@ const VerifyClearIndicator = <
|
|||||||
show={show}
|
show={show}
|
||||||
>
|
>
|
||||||
<Modal
|
<Modal
|
||||||
subTitle={intl.formatMessage(messages.clearBlacklistedTagsConfirm)}
|
subTitle={intl.formatMessage(messages.clearBlocklistedTagsConfirm)}
|
||||||
okText={intl.formatMessage(messages.yes)}
|
okText={intl.formatMessage(messages.yes)}
|
||||||
cancelText={intl.formatMessage(messages.no)}
|
cancelText={intl.formatMessage(messages.no)}
|
||||||
onOk={clearValue}
|
onOk={clearValue}
|
||||||
@@ -406,4 +406,4 @@ const VerifyClearIndicator = <
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default BlacklistedTagsSelector;
|
export default BlocklistedTagsSelector;
|
||||||
@@ -188,8 +188,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blacklistVisibility = hasPermission(
|
const blocklistVisibility = hasPermission(
|
||||||
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
|
[Permission.MANAGE_BLOCKLIST, Permission.VIEW_BLOCKLIST],
|
||||||
{ type: 'or' }
|
{ type: 'or' }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -349,8 +349,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
isEmpty={data.parts.length === 0}
|
isEmpty={data.parts.length === 0}
|
||||||
items={data.parts
|
items={data.parts
|
||||||
.filter((title) => {
|
.filter((title) => {
|
||||||
if (!blacklistVisibility)
|
if (!blocklistVisibility)
|
||||||
return title.mediaInfo?.status !== MediaStatus.BLACKLISTED;
|
return title.mediaInfo?.status !== MediaStatus.BLOCKLISTED;
|
||||||
return title;
|
return title;
|
||||||
})
|
})
|
||||||
.map((title) => (
|
.map((title) => (
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ const ListView = ({
|
|||||||
const { hasPermission } = useUser();
|
const { hasPermission } = useUser();
|
||||||
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
|
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
|
||||||
|
|
||||||
const blacklistVisibility = hasPermission(
|
const blocklistVisibility = hasPermission(
|
||||||
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
|
[Permission.MANAGE_BLOCKLIST, Permission.VIEW_BLOCKLIST],
|
||||||
{ type: 'or' }
|
{ type: 'or' }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -66,10 +66,10 @@ const ListView = ({
|
|||||||
})}
|
})}
|
||||||
{items
|
{items
|
||||||
?.filter((title) => {
|
?.filter((title) => {
|
||||||
if (!blacklistVisibility)
|
if (!blocklistVisibility)
|
||||||
return (
|
return (
|
||||||
(title as TvResult | MovieResult).mediaInfo?.status !==
|
(title as TvResult | MovieResult).mediaInfo?.status !==
|
||||||
MediaStatus.BLACKLISTED
|
MediaStatus.BLOCKLISTED
|
||||||
);
|
);
|
||||||
return title;
|
return title;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const StatusBadgeMini = ({
|
|||||||
);
|
);
|
||||||
indicatorIcon = <BellIcon />;
|
indicatorIcon = <BellIcon />;
|
||||||
break;
|
break;
|
||||||
case MediaStatus.BLACKLISTED:
|
case MediaStatus.BLOCKLISTED:
|
||||||
badgeStyle.push('bg-red-500 border-white-400 ring-white-400 text-white');
|
badgeStyle.push('bg-red-500 border-white-400 ring-white-400 text-white');
|
||||||
indicatorIcon = <EyeSlashIcon />;
|
indicatorIcon = <EyeSlashIcon />;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -100,14 +100,14 @@ const MobileMenu = ({
|
|||||||
activeRegExp: /^\/requests/,
|
activeRegExp: /^\/requests/,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/blacklist',
|
href: '/blocklist',
|
||||||
content: intl.formatMessage(menuMessages.blacklist),
|
content: intl.formatMessage(menuMessages.blocklist),
|
||||||
svgIcon: <EyeSlashIcon className="h-6 w-6" />,
|
svgIcon: <EyeSlashIcon className="h-6 w-6" />,
|
||||||
svgIconSelected: <FilledEyeSlashIcon className="h-6 w-6" />,
|
svgIconSelected: <FilledEyeSlashIcon className="h-6 w-6" />,
|
||||||
activeRegExp: /^\/blacklist/,
|
activeRegExp: /^\/blocklist/,
|
||||||
requiredPermission: [
|
requiredPermission: [
|
||||||
Permission.MANAGE_BLACKLIST,
|
Permission.MANAGE_BLOCKLIST,
|
||||||
Permission.VIEW_BLACKLIST,
|
Permission.VIEW_BLOCKLIST,
|
||||||
],
|
],
|
||||||
permissionType: 'or',
|
permissionType: 'or',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const menuMessages = defineMessages('components.Layout.Sidebar', {
|
|||||||
browsemovies: 'Movies',
|
browsemovies: 'Movies',
|
||||||
browsetv: 'Series',
|
browsetv: 'Series',
|
||||||
requests: 'Requests',
|
requests: 'Requests',
|
||||||
blacklist: 'Blacklist',
|
blocklist: 'Blocklist',
|
||||||
issues: 'Issues',
|
issues: 'Issues',
|
||||||
users: 'Users',
|
users: 'Users',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
@@ -79,13 +79,13 @@ const SidebarLinks: SidebarLinkProps[] = [
|
|||||||
activeRegExp: /^\/requests/,
|
activeRegExp: /^\/requests/,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/blacklist',
|
href: '/blocklist',
|
||||||
messagesKey: 'blacklist',
|
messagesKey: 'blocklist',
|
||||||
svgIcon: <EyeSlashIcon className="mr-3 h-6 w-6" />,
|
svgIcon: <EyeSlashIcon className="mr-3 h-6 w-6" />,
|
||||||
activeRegExp: /^\/blacklist/,
|
activeRegExp: /^\/blocklist/,
|
||||||
requiredPermission: [
|
requiredPermission: [
|
||||||
Permission.MANAGE_BLACKLIST,
|
Permission.MANAGE_BLOCKLIST,
|
||||||
Permission.VIEW_BLACKLIST,
|
Permission.VIEW_BLOCKLIST,
|
||||||
],
|
],
|
||||||
permissionType: 'or',
|
permissionType: 'or',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import BlacklistBlock from '@app/components/BlacklistBlock';
|
import BlocklistBlock from '@app/components/BlocklistBlock';
|
||||||
import Button from '@app/components/Common/Button';
|
import Button from '@app/components/Common/Button';
|
||||||
import CachedImage from '@app/components/Common/CachedImage';
|
import CachedImage from '@app/components/Common/CachedImage';
|
||||||
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
||||||
@@ -314,13 +314,13 @@ const ManageSlideOver = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data.mediaInfo?.status === MediaStatus.BLACKLISTED && (
|
{data.mediaInfo?.status === MediaStatus.BLOCKLISTED && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 text-xl font-bold">
|
<h3 className="mb-2 text-xl font-bold">
|
||||||
{intl.formatMessage(globalMessages.blacklist)}
|
{intl.formatMessage(globalMessages.blocklist)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
|
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
|
||||||
<BlacklistBlock
|
<BlocklistBlock
|
||||||
tmdbId={data.mediaInfo.tmdbId}
|
tmdbId={data.mediaInfo.tmdbId}
|
||||||
onUpdate={() => revalidate()}
|
onUpdate={() => revalidate()}
|
||||||
onDelete={() => onClose()}
|
onDelete={() => onClose()}
|
||||||
@@ -651,7 +651,7 @@ const ManageSlideOver = ({
|
|||||||
)}
|
)}
|
||||||
{hasPermission(Permission.ADMIN) &&
|
{hasPermission(Permission.ADMIN) &&
|
||||||
data?.mediaInfo &&
|
data?.mediaInfo &&
|
||||||
data.mediaInfo.status !== MediaStatus.BLACKLISTED && (
|
data.mediaInfo.status !== MediaStatus.BLOCKLISTED && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 text-xl font-bold">
|
<h3 className="mb-2 text-xl font-bold">
|
||||||
{intl.formatMessage(messages.manageModalAdvanced)}
|
{intl.formatMessage(messages.manageModalAdvanced)}
|
||||||
|
|||||||
@@ -74,11 +74,11 @@ const MediaSlider = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings.currentSettings.hideBlacklisted) {
|
if (settings.currentSettings.hideBlocklisted) {
|
||||||
titles = titles.filter(
|
titles = titles.filter(
|
||||||
(i) =>
|
(i) =>
|
||||||
(i.mediaType === 'movie' || i.mediaType === 'tv') &&
|
(i.mediaType === 'movie' || i.mediaType === 'tv') &&
|
||||||
i.mediaInfo?.status !== MediaStatus.BLACKLISTED
|
i.mediaInfo?.status !== MediaStatus.BLOCKLISTED
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,18 +102,18 @@ const MediaSlider = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blacklistVisibility = hasPermission(
|
const blocklistVisibility = hasPermission(
|
||||||
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
|
[Permission.MANAGE_BLOCKLIST, Permission.VIEW_BLOCKLIST],
|
||||||
{ type: 'or' }
|
{ type: 'or' }
|
||||||
);
|
);
|
||||||
|
|
||||||
const finalTitles = titles
|
const finalTitles = titles
|
||||||
.slice(0, 20)
|
.slice(0, 20)
|
||||||
.filter((title) => {
|
.filter((title) => {
|
||||||
if (!blacklistVisibility)
|
if (!blocklistVisibility)
|
||||||
return (
|
return (
|
||||||
(title as TvResult | MovieResult).mediaInfo?.status !==
|
(title as TvResult | MovieResult).mediaInfo?.status !==
|
||||||
MediaStatus.BLACKLISTED
|
MediaStatus.BLOCKLISTED
|
||||||
);
|
);
|
||||||
return title;
|
return title;
|
||||||
})
|
})
|
||||||
|
|||||||