Compare commits
1 Commits
preview-pl
...
preview-me
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b30794dd20 |
@@ -26,7 +26,7 @@
|
|||||||
- 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 & blocklisting media.
|
- Support for watchlisting & blacklisting media.
|
||||||
|
|
||||||
With more features on the way! Check out our [issue tracker](/../../issues) to see the features which have already been requested.
|
With more features on the way! Check out our [issue tracker](/../../issues) to see the features which have already been requested.
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
"discoverRegion": "",
|
"discoverRegion": "",
|
||||||
"streamingRegion": "",
|
"streamingRegion": "",
|
||||||
"originalLanguage": "",
|
"originalLanguage": "",
|
||||||
"blocklistedTags": "",
|
"blacklistedTags": "",
|
||||||
"blocklistedTagsLimit": 50,
|
"blacklistedTagsLimit": 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 **Blocklist** media.
|
- Easily **Watchlist** or **Blacklist** 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, blocklist etc. are stored in the database (either SQLite or PostgreSQL).
|
Apart from the settings, all other data—including user accounts, media requests, blacklist etc. are stored in the database (either SQLite or PostgreSQL).
|
||||||
|
|
||||||
# Backup
|
# 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.
|
||||||
|
|
||||||
## Blocklist Content with Tags and Limit Content Blocklisted per Tag
|
## Blacklist Content with Tags and Limit Content Blacklisted per Tag
|
||||||
|
|
||||||
These settings blocklist any TV shows or movies that have one of the entered tags. The "Process Blocklisted Tags" job adds entries to the blocklist based on the configured blocklisted tags. If a blocklisted tag is removed, any media blocklisted under that tag will be removed from the blocklist when the "Process Blocklisted Tags" job runs.
|
These settings blacklist any TV shows or movies that have one of the entered tags. The "Process Blacklisted Tags" job adds entries to the blacklist based on the configured blacklisted tags. If a blacklisted tag is removed, any media blacklisted under that tag will be removed from the blacklist when the "Process Blacklisted Tags" job runs.
|
||||||
|
|
||||||
The limit setting determines how many pages per tag the job will process, with each page containing 20 entries. The job cycles through all 16 available discovery sort options, querying the defined number of pages to blocklist media that is most likely to appear at the top of each sort. Higher limits will create a more accurate blocklist, but will require more storage.
|
The limit setting determines how many pages per tag the job will process, with each page containing 20 entries. The job cycles through all 16 available discovery sort options, querying the defined number of pages to blacklist media that is most likely to appear at the top of each sort. Higher limits will create a more accurate blacklist, but will require more storage.
|
||||||
|
|
||||||
Blocklisted tags are disabled until at least one tag is entered. These settings cannot be overridden in user settings.
|
Blacklisted tags are disabled until at least one tag is entered. These settings cannot be overridden in user settings.
|
||||||
|
|
||||||
## Hide Available Media
|
## 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 Blocklisted Items
|
## Hide Blacklisted Items
|
||||||
|
|
||||||
When enabled, media that has been blocklisted will not appear on the "Discover" home page, for all administrators. This can be useful to hide content that you don't want to see, such as content with specific tags or content that has been manually blocklisted when you have the "Manage Blocklist" permission.
|
When enabled, media that has been blacklisted will not appear on the "Discover" home page, for all administrators. This can be useful to hide content that you don't want to see, such as content with specific tags or content that has been manually blacklisted when you have the "Manage Blacklist" permission.
|
||||||
|
|
||||||
This setting is **disabled** by default.
|
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.
|
||||||
* **Blocklist for movies, series, and tags**: Allows permitted users to hide movies, series, or tags from regular users.
|
* **Blacklist for movies, series, and tags**: Allows permitted users to hide movies, series, or tags from regular users.
|
||||||
* **Override rules**: Adjust default request settings based on conditions such as user, tag, or other criteria.
|
* **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: 6.0 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 38 KiB |
BIN
public/apple-splash-1179-2556.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 43 KiB |
BIN
public/apple-splash-1290-2796.jpg
Normal file
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
BIN
public/apple-splash-1488-2266.jpg
Normal file
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 47 KiB |
BIN
public/apple-splash-1640-2360.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 47 KiB |
BIN
public/apple-splash-2266-1488.jpg
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
public/apple-splash-2360-1640.jpg
Normal file
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 33 KiB |
BIN
public/apple-splash-2556-1179.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 37 KiB |
BIN
public/apple-splash-2796-1290.jpg
Normal file
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 774 B After Width: | Height: | Size: 821 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.2 KiB |
BIN
public/os_logo_square.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 137 KiB |
156
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: blocklist
|
- name: blacklist
|
||||||
description: Blocklisted media from discovery page.
|
description: Blacklisted 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:
|
||||||
Blocklist:
|
Blacklist:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
tmdbId:
|
tmdbId:
|
||||||
@@ -4529,123 +4529,12 @@ paths:
|
|||||||
restricted:
|
restricted:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
example: false
|
||||||
/blocklist:
|
|
||||||
get:
|
|
||||||
summary: Returns blocklisted items
|
|
||||||
description: Returns list of all blocklisted media
|
|
||||||
tags:
|
|
||||||
- blocklist
|
|
||||||
parameters:
|
|
||||||
- in: query
|
|
||||||
name: take
|
|
||||||
schema:
|
|
||||||
type: number
|
|
||||||
nullable: true
|
|
||||||
example: 25
|
|
||||||
- in: query
|
|
||||||
name: skip
|
|
||||||
schema:
|
|
||||||
type: number
|
|
||||||
nullable: true
|
|
||||||
example: 0
|
|
||||||
- in: query
|
|
||||||
name: search
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
nullable: true
|
|
||||||
example: dune
|
|
||||||
- in: query
|
|
||||||
name: filter
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
enum: [all, manual, blocklistedTags]
|
|
||||||
default: manual
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Blocklisted items returned
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
pageInfo:
|
|
||||||
$ref: '#/components/schemas/PageInfo'
|
|
||||||
results:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
user:
|
|
||||||
$ref: '#/components/schemas/User'
|
|
||||||
createdAt:
|
|
||||||
type: string
|
|
||||||
example: 2024-04-21T01:55:44.000Z
|
|
||||||
id:
|
|
||||||
type: number
|
|
||||||
example: 1
|
|
||||||
mediaType:
|
|
||||||
type: string
|
|
||||||
example: movie
|
|
||||||
title:
|
|
||||||
type: string
|
|
||||||
example: Dune
|
|
||||||
tmdbId:
|
|
||||||
type: number
|
|
||||||
example: 438631
|
|
||||||
post:
|
|
||||||
summary: Add media to blocklist
|
|
||||||
tags:
|
|
||||||
- blocklist
|
|
||||||
requestBody:
|
|
||||||
required: true
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
$ref: '#/components/schemas/Blocklist'
|
|
||||||
responses:
|
|
||||||
'201':
|
|
||||||
description: Item succesfully blocklisted
|
|
||||||
'412':
|
|
||||||
description: Item has already been blocklisted
|
|
||||||
/blocklist/{tmdbId}:
|
|
||||||
get:
|
|
||||||
summary: Get media from blocklist
|
|
||||||
tags:
|
|
||||||
- blocklist
|
|
||||||
parameters:
|
|
||||||
- in: path
|
|
||||||
name: tmdbId
|
|
||||||
description: tmdbId ID
|
|
||||||
required: true
|
|
||||||
example: '1'
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: Blocklist details in JSON
|
|
||||||
delete:
|
|
||||||
summary: Remove media from blocklist
|
|
||||||
tags:
|
|
||||||
- blocklist
|
|
||||||
parameters:
|
|
||||||
- in: path
|
|
||||||
name: tmdbId
|
|
||||||
description: tmdbId ID
|
|
||||||
required: true
|
|
||||||
example: '1'
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'204':
|
|
||||||
description: Succesfully removed media item
|
|
||||||
/blacklist:
|
/blacklist:
|
||||||
get:
|
get:
|
||||||
summary: Returns blocklisted items
|
summary: Returns blacklisted items
|
||||||
description: |
|
description: Returns list of all blacklisted media
|
||||||
**DEPRECATED**: Use `/blocklist` instead. This endpoint will be deprecated soon.
|
|
||||||
deprecated: true
|
|
||||||
tags:
|
tags:
|
||||||
- blocklist
|
- settings
|
||||||
parameters:
|
parameters:
|
||||||
- in: query
|
- in: query
|
||||||
name: take
|
name: take
|
||||||
@@ -4669,11 +4558,11 @@ paths:
|
|||||||
name: filter
|
name: filter
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
enum: [all, manual, blocklistedTags]
|
enum: [all, manual, blacklistedTags]
|
||||||
default: manual
|
default: manual
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Blocklisted items returned
|
description: Blacklisted items returned
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
@@ -4704,31 +4593,25 @@ paths:
|
|||||||
type: number
|
type: number
|
||||||
example: 438631
|
example: 438631
|
||||||
post:
|
post:
|
||||||
summary: Add media to blocklist
|
summary: Add media to blacklist
|
||||||
description: |
|
|
||||||
**DEPRECATED**: Use `/blocklist` instead. This endpoint will be deprecated soon.
|
|
||||||
deprecated: true
|
|
||||||
tags:
|
tags:
|
||||||
- blocklist
|
- blacklist
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Blocklist'
|
$ref: '#/components/schemas/Blacklist'
|
||||||
responses:
|
responses:
|
||||||
'201':
|
'201':
|
||||||
description: Item succesfully blocklisted
|
description: Item succesfully blacklisted
|
||||||
'412':
|
'412':
|
||||||
description: Item has already been blocklisted
|
description: Item has already been blacklisted
|
||||||
/blacklist/{tmdbId}:
|
/blacklist/{tmdbId}:
|
||||||
get:
|
get:
|
||||||
summary: Get media from blocklist
|
summary: Get media from blacklist
|
||||||
description: |
|
|
||||||
**DEPRECATED**: Use `/blocklist/{tmdbId}` instead. This endpoint will be deprecated soon.
|
|
||||||
deprecated: true
|
|
||||||
tags:
|
tags:
|
||||||
- blocklist
|
- blacklist
|
||||||
parameters:
|
parameters:
|
||||||
- in: path
|
- in: path
|
||||||
name: tmdbId
|
name: tmdbId
|
||||||
@@ -4739,14 +4622,11 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Blocklist details in JSON
|
description: Blacklist details in JSON
|
||||||
delete:
|
delete:
|
||||||
summary: Remove media from blocklist
|
summary: Remove media from blacklist
|
||||||
description: |
|
|
||||||
**DEPRECATED**: Use `/blocklist/{tmdbId}` instead. This endpoint will be deprecated soon.
|
|
||||||
deprecated: true
|
|
||||||
tags:
|
tags:
|
||||||
- blocklist
|
- blacklist
|
||||||
parameters:
|
parameters:
|
||||||
- in: path
|
- in: path
|
||||||
name: tmdbId
|
name: tmdbId
|
||||||
|
|||||||
@@ -27,13 +27,10 @@ export interface PlexLibraryItem {
|
|||||||
Media: Media[];
|
Media: Media[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PlexLibraryResponseRaw {
|
interface PlexLibraryResponse {
|
||||||
MediaContainer: {
|
MediaContainer: {
|
||||||
totalSize?: number;
|
totalSize: number;
|
||||||
size?: number;
|
Metadata: PlexLibraryItem[];
|
||||||
Metadata?: PlexLibraryItem[];
|
|
||||||
Directory?: PlexLibraryItem[];
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +177,7 @@ class PlexAPI extends ExternalAPI {
|
|||||||
id: string,
|
id: string,
|
||||||
{ offset = 0, size = 50 }: { offset?: number; size?: number } = {}
|
{ offset = 0, size = 50 }: { offset?: number; size?: number } = {}
|
||||||
): Promise<{ totalSize: number; items: PlexLibraryItem[] }> {
|
): Promise<{ totalSize: number; items: PlexLibraryItem[] }> {
|
||||||
const response = await this.get<PlexLibraryResponseRaw>(
|
const response = await this.get<PlexLibraryResponse>(
|
||||||
`/library/sections/${id}/all?includeGuids=1`,
|
`/library/sections/${id}/all?includeGuids=1`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
@@ -190,26 +187,9 @@ class PlexAPI extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const container = response.MediaContainer;
|
|
||||||
const metadataLength = container.Metadata?.length ?? 0;
|
|
||||||
const directoryLength = container.Directory?.length ?? 0;
|
|
||||||
|
|
||||||
logger.debug('Plex getLibraryContents raw response', {
|
|
||||||
label: 'Plex API',
|
|
||||||
libraryId: id,
|
|
||||||
offset,
|
|
||||||
metadataLength,
|
|
||||||
directoryLength,
|
|
||||||
totalSize: container.totalSize,
|
|
||||||
size: container.size,
|
|
||||||
keys: Object.keys(container).filter((k) =>
|
|
||||||
['Metadata', 'Directory', 'totalSize', 'size'].includes(k)
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalSize: container.totalSize ?? 0,
|
totalSize: response.MediaContainer.totalSize,
|
||||||
items: container.Metadata ?? [],
|
items: response.MediaContainer.Metadata ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +221,7 @@ class PlexAPI extends ExternalAPI {
|
|||||||
},
|
},
|
||||||
mediaType: 'movie' | 'show'
|
mediaType: 'movie' | 'show'
|
||||||
): Promise<PlexLibraryItem[]> {
|
): Promise<PlexLibraryItem[]> {
|
||||||
const response = await this.get<PlexLibraryResponseRaw>(
|
const response = await this.get<PlexLibraryResponse>(
|
||||||
`/library/sections/${id}/all?type=${
|
`/library/sections/${id}/all?type=${
|
||||||
mediaType === 'show' ? '4' : '1'
|
mediaType === 'show' ? '4' : '1'
|
||||||
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
|
}&sort=addedAt%3Adesc&addedAt>>=${Math.floor(options.addedAt / 1000)}`,
|
||||||
@@ -253,21 +233,7 @@ class PlexAPI extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const container = response.MediaContainer;
|
return response.MediaContainer.Metadata;
|
||||||
const items = container.Metadata ?? [];
|
|
||||||
|
|
||||||
logger.debug('Plex getRecentlyAdded raw response', {
|
|
||||||
label: 'Plex API',
|
|
||||||
libraryId: id,
|
|
||||||
mediaType,
|
|
||||||
addedAtFilter: options.addedAt,
|
|
||||||
addedAtFilterDate: new Date(options.addedAt).toISOString(),
|
|
||||||
metadataLength: container.Metadata?.length ?? 0,
|
|
||||||
directoryLength: container.Directory?.length ?? 0,
|
|
||||||
itemsReturned: items.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ export enum MediaStatus {
|
|||||||
PROCESSING,
|
PROCESSING,
|
||||||
PARTIALLY_AVAILABLE,
|
PARTIALLY_AVAILABLE,
|
||||||
AVAILABLE,
|
AVAILABLE,
|
||||||
BLOCKLISTED,
|
BLACKLISTED,
|
||||||
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 { BlocklistItem } from '@server/interfaces/api/blocklistInterfaces';
|
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
|
||||||
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 Blocklist implements BlocklistItem {
|
export class Blacklist implements BlacklistItem {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
public id: number;
|
public id: number;
|
||||||
|
|
||||||
@@ -38,65 +38,65 @@ export class Blocklist implements BlocklistItem {
|
|||||||
})
|
})
|
||||||
user?: User;
|
user?: User;
|
||||||
|
|
||||||
@OneToOne(() => Media, (media) => media.blocklist, {
|
@OneToOne(() => Media, (media) => media.blacklist, {
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
})
|
})
|
||||||
@JoinColumn()
|
@JoinColumn()
|
||||||
public media: Media;
|
public media: Media;
|
||||||
|
|
||||||
@Column({ nullable: true, type: 'varchar' })
|
@Column({ nullable: true, type: 'varchar' })
|
||||||
public blocklistedTags?: string;
|
public blacklistedTags?: string;
|
||||||
|
|
||||||
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
@DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' })
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|
||||||
constructor(init?: Partial<Blocklist>) {
|
constructor(init?: Partial<Blacklist>) {
|
||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async addToBlocklist(
|
public static async addToBlacklist(
|
||||||
{
|
{
|
||||||
blocklistRequest,
|
blacklistRequest,
|
||||||
}: {
|
}: {
|
||||||
blocklistRequest: {
|
blacklistRequest: {
|
||||||
mediaType: MediaType;
|
mediaType: MediaType;
|
||||||
title?: ZodOptional<ZodString>['_output'];
|
title?: ZodOptional<ZodString>['_output'];
|
||||||
tmdbId: ZodNumber['_output'];
|
tmdbId: ZodNumber['_output'];
|
||||||
blocklistedTags?: string;
|
blacklistedTags?: string;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
entityManager?: EntityManager
|
entityManager?: EntityManager
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const em = entityManager ?? dataSource;
|
const em = entityManager ?? dataSource;
|
||||||
const blocklist = new this({
|
const blacklist = new this({
|
||||||
...blocklistRequest,
|
...blacklistRequest,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mediaRepository = em.getRepository(Media);
|
const mediaRepository = em.getRepository(Media);
|
||||||
let media = await mediaRepository.findOne({
|
let media = await mediaRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
tmdbId: blocklistRequest.tmdbId,
|
tmdbId: blacklistRequest.tmdbId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const blocklistRepository = em.getRepository(this);
|
const blacklistRepository = em.getRepository(this);
|
||||||
|
|
||||||
await blocklistRepository.save(blocklist);
|
await blacklistRepository.save(blacklist);
|
||||||
|
|
||||||
if (!media) {
|
if (!media) {
|
||||||
media = new Media({
|
media = new Media({
|
||||||
tmdbId: blocklistRequest.tmdbId,
|
tmdbId: blacklistRequest.tmdbId,
|
||||||
status: MediaStatus.BLOCKLISTED,
|
status: MediaStatus.BLACKLISTED,
|
||||||
status4k: MediaStatus.BLOCKLISTED,
|
status4k: MediaStatus.BLACKLISTED,
|
||||||
mediaType: blocklistRequest.mediaType,
|
mediaType: blacklistRequest.mediaType,
|
||||||
blocklist: Promise.resolve(blocklist),
|
blacklist: Promise.resolve(blacklist),
|
||||||
});
|
});
|
||||||
|
|
||||||
await mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
} else {
|
} else {
|
||||||
media.blocklist = Promise.resolve(blocklist);
|
media.blacklist = Promise.resolve(blacklist);
|
||||||
media.status = MediaStatus.BLOCKLISTED;
|
media.status = MediaStatus.BLACKLISTED;
|
||||||
media.status4k = MediaStatus.BLOCKLISTED;
|
media.status4k = MediaStatus.BLACKLISTED;
|
||||||
|
|
||||||
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 { Blocklist } from '@server/entity/Blocklist';
|
import { Blacklist } from '@server/entity/Blacklist';
|
||||||
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(() => Blocklist, (blocklist) => blocklist.media)
|
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
|
||||||
public blocklist: Promise<Blocklist>;
|
public blacklist: Promise<Blacklist>;
|
||||||
|
|
||||||
@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 BlocklistedMediaError extends Error {}
|
export class BlacklistedMediaError 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.BLOCKLISTED) {
|
if (media.status === MediaStatus.BLACKLISTED) {
|
||||||
logger.warn('Request for media blocked due to being blocklisted', {
|
logger.warn('Request for media blocked due to being blacklisted', {
|
||||||
tmdbId: tmdbMedia.id,
|
tmdbId: tmdbMedia.id,
|
||||||
mediaType: requestBody.mediaType,
|
mediaType: requestBody.mediaType,
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
});
|
});
|
||||||
|
|
||||||
throw new BlocklistedMediaError('This media is blocklisted.');
|
throw new BlacklistedMediaError('This media is blacklisted.');
|
||||||
}
|
}
|
||||||
|
|
||||||
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 BlocklistItem {
|
export interface BlacklistItem {
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
mediaType: 'movie' | 'tv';
|
mediaType: 'movie' | 'tv';
|
||||||
title?: string;
|
title?: string;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
user?: User;
|
user?: User;
|
||||||
blocklistedTags?: string;
|
blacklistedTags?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlocklistResultsResponse extends PaginatedResponse {
|
export interface BlacklistResultsResponse extends PaginatedResponse {
|
||||||
results: BlocklistItem[];
|
results: BlacklistItem[];
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ export interface PublicSettingsResponse {
|
|||||||
applicationTitle: string;
|
applicationTitle: string;
|
||||||
applicationUrl: string;
|
applicationUrl: string;
|
||||||
hideAvailable: boolean;
|
hideAvailable: boolean;
|
||||||
hideBlocklisted: boolean;
|
hideBlacklisted: 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 { Blocklist } from '@server/entity/Blocklist';
|
import { Blacklist } from '@server/entity/Blacklist';
|
||||||
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 BlocklistedTagProcessor implements RunnableScanner<StatusBase> {
|
class BlacklistedTagProcessor 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 BlocklistedTagProcessor implements RunnableScanner<StatusBase> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await dataSource.transaction(async (em) => {
|
await dataSource.transaction(async (em) => {
|
||||||
await this.cleanBlocklist(em);
|
await this.cleanBlacklist(em);
|
||||||
await this.createBlocklistEntries(em);
|
await this.createBlacklistEntries(em);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof AbortTransaction) {
|
if (err instanceof AbortTransaction) {
|
||||||
logger.info('Aborting job: Process Blocklisted Tags', {
|
logger.info('Aborting job: Process Blacklisted Tags', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -64,37 +64,37 @@ class BlocklistedTagProcessor implements RunnableScanner<StatusBase> {
|
|||||||
this.cancel();
|
this.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createBlocklistEntries(em: EntityManager) {
|
private async createBlacklistEntries(em: EntityManager) {
|
||||||
const tmdb = createTmdbWithRegionLanguage();
|
const tmdb = createTmdbWithRegionLanguage();
|
||||||
|
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
const blocklistedTags = settings.main.blocklistedTags;
|
const blacklistedTags = settings.main.blacklistedTags;
|
||||||
const blocklistedTagsArr = blocklistedTags.split(',');
|
const blacklistedTagsArr = blacklistedTags.split(',');
|
||||||
|
|
||||||
const pageLimit = settings.main.blocklistedTagsLimit;
|
const pageLimit = settings.main.blacklistedTagsLimit;
|
||||||
const invalidKeywords = new Set<string>();
|
const invalidKeywords = new Set<string>();
|
||||||
|
|
||||||
if (blocklistedTags.length === 0) {
|
if (blacklistedTags.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 * blocklistedTagsArr.length * pageLimit * SortOptionsIterable.length;
|
2 * blacklistedTagsArr.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 blocklistedTagsArr) {
|
for (const tag of blacklistedTagsArr) {
|
||||||
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 blocklisted tags', {
|
logger.warn('Skipping invalid keyword in blacklisted tags', {
|
||||||
label: 'Blocklisted Tags Processor',
|
label: 'Blacklisted Tags Processor',
|
||||||
keywordId: tag,
|
keywordId: tag,
|
||||||
});
|
});
|
||||||
invalidKeywords.add(tag);
|
invalidKeywords.add(tag);
|
||||||
@@ -134,8 +134,8 @@ class BlocklistedTagProcessor implements RunnableScanner<StatusBase> {
|
|||||||
queryMax = response.total_pages;
|
queryMax = response.total_pages;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error processing keyword in blocklisted tags', {
|
logger.error('Error processing keyword in blacklisted tags', {
|
||||||
label: 'Blocklisted Tags Processor',
|
label: 'Blacklisted Tags Processor',
|
||||||
keywordId: tag,
|
keywordId: tag,
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
});
|
});
|
||||||
@@ -145,19 +145,19 @@ class BlocklistedTagProcessor implements RunnableScanner<StatusBase> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (invalidKeywords.size > 0) {
|
if (invalidKeywords.size > 0) {
|
||||||
const currentTags = blocklistedTagsArr.filter(
|
const currentTags = blacklistedTagsArr.filter(
|
||||||
(tag) => !invalidKeywords.has(tag)
|
(tag) => !invalidKeywords.has(tag)
|
||||||
);
|
);
|
||||||
const cleanedTags = currentTags.join(',');
|
const cleanedTags = currentTags.join(',');
|
||||||
|
|
||||||
if (cleanedTags !== blocklistedTags) {
|
if (cleanedTags !== blacklistedTags) {
|
||||||
settings.main.blocklistedTags = cleanedTags;
|
settings.main.blacklistedTags = cleanedTags;
|
||||||
await settings.save();
|
await settings.save();
|
||||||
|
|
||||||
logger.info('Cleaned up invalid keywords from settings', {
|
logger.info('Cleaned up invalid keywords from settings', {
|
||||||
label: 'Blocklisted Tags Processor',
|
label: 'Blacklisted Tags Processor',
|
||||||
removedKeywords: Array.from(invalidKeywords),
|
removedKeywords: Array.from(invalidKeywords),
|
||||||
newBlocklistedTags: cleanedTags,
|
newBlacklistedTags: cleanedTags,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,33 +169,33 @@ class BlocklistedTagProcessor implements RunnableScanner<StatusBase> {
|
|||||||
mediaType: MediaType,
|
mediaType: MediaType,
|
||||||
em: EntityManager
|
em: EntityManager
|
||||||
) {
|
) {
|
||||||
const blocklistRepository = em.getRepository(Blocklist);
|
const blacklistRepository = em.getRepository(Blacklist);
|
||||||
|
|
||||||
for (const entry of response.results) {
|
for (const entry of response.results) {
|
||||||
const blocklistEntry = await blocklistRepository.findOne({
|
const blacklistEntry = await blacklistRepository.findOne({
|
||||||
where: { tmdbId: entry.id },
|
where: { tmdbId: entry.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (blocklistEntry) {
|
if (blacklistEntry) {
|
||||||
// Don't mark manual blocklists with tags
|
// Don't mark manual blacklists with tags
|
||||||
// If media wasn't previously blocklisted for this tag, add the tag to the media's blocklist
|
// If media wasn't previously blacklisted for this tag, add the tag to the media's blacklist
|
||||||
if (
|
if (
|
||||||
blocklistEntry.blocklistedTags &&
|
blacklistEntry.blacklistedTags &&
|
||||||
!blocklistEntry.blocklistedTags.includes(`,${keywordId},`)
|
!blacklistEntry.blacklistedTags.includes(`,${keywordId},`)
|
||||||
) {
|
) {
|
||||||
await blocklistRepository.update(blocklistEntry.id, {
|
await blacklistRepository.update(blacklistEntry.id, {
|
||||||
blocklistedTags: `${blocklistEntry.blocklistedTags}${keywordId},`,
|
blacklistedTags: `${blacklistEntry.blacklistedTags}${keywordId},`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Media wasn't previously blocklisted, add it to the blocklist
|
// Media wasn't previously blacklisted, add it to the blacklist
|
||||||
await Blocklist.addToBlocklist(
|
await Blacklist.addToBlacklist(
|
||||||
{
|
{
|
||||||
blocklistRequest: {
|
blacklistRequest: {
|
||||||
mediaType,
|
mediaType,
|
||||||
title: 'title' in entry ? entry.title : entry.name,
|
title: 'title' in entry ? entry.title : entry.name,
|
||||||
tmdbId: entry.id,
|
tmdbId: entry.id,
|
||||||
blocklistedTags: `,${keywordId},`,
|
blacklistedTags: `,${keywordId},`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
em
|
em
|
||||||
@@ -204,22 +204,22 @@ class BlocklistedTagProcessor implements RunnableScanner<StatusBase> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cleanBlocklist(em: EntityManager) {
|
private async cleanBlacklist(em: EntityManager) {
|
||||||
// Remove blocklist and media entries blocklisted by tags
|
// Remove blacklist and media entries blacklisted 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(Blocklist, 'blist', 'blist.tmdbId = media.tmdbId')
|
.innerJoinAndSelect(Blacklist, 'blist', 'blist.tmdbId = media.tmdbId')
|
||||||
.where(`blist.blocklistedTags IS NOT NULL`)
|
.where(`blist.blacklistedTags 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 blocklist entries via cascading
|
await mediaRepository.remove(mediaToRemove.slice(i, i + 500)); // This also deletes the blacklist entries via cascading
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const blocklistedTagsProcessor = new BlocklistedTagProcessor();
|
const blacklistedTagsProcessor = new BlacklistedTagProcessor();
|
||||||
|
|
||||||
export default blocklistedTagsProcessor;
|
export default blacklistedTagsProcessor;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MediaServerType } from '@server/constants/server';
|
import { MediaServerType } from '@server/constants/server';
|
||||||
import blocklistedTagsProcessor from '@server/job/blocklistedTagsProcessor';
|
import blacklistedTagsProcessor from '@server/job/blacklistedTagsProcessor';
|
||||||
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-blocklisted-tags',
|
id: 'process-blacklisted-tags',
|
||||||
name: 'Process Blocklisted Tags',
|
name: 'Process Blacklisted Tags',
|
||||||
type: 'process',
|
type: 'process',
|
||||||
interval: 'days',
|
interval: 'days',
|
||||||
cronSchedule: jobs['process-blocklisted-tags'].schedule,
|
cronSchedule: jobs['process-blacklisted-tags'].schedule,
|
||||||
job: schedule.scheduleJob(jobs['process-blocklisted-tags'].schedule, () => {
|
job: schedule.scheduleJob(jobs['process-blacklisted-tags'].schedule, () => {
|
||||||
logger.info('Starting scheduled job: Process Blocklisted Tags', {
|
logger.info('Starting scheduled job: Process Blacklisted Tags', {
|
||||||
label: 'Jobs',
|
label: 'Jobs',
|
||||||
});
|
});
|
||||||
blocklistedTagsProcessor.run();
|
blacklistedTagsProcessor.run();
|
||||||
}),
|
}),
|
||||||
running: () => blocklistedTagsProcessor.status().running,
|
running: () => blacklistedTagsProcessor.status().running,
|
||||||
cancelFn: () => blocklistedTagsProcessor.cancel(),
|
cancelFn: () => blacklistedTagsProcessor.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_BLOCKLIST = 268435456,
|
MANAGE_BLACKLIST = 268435456,
|
||||||
VIEW_BLOCKLIST = 1073741824,
|
VIEW_BLACKLIST = 1073741824,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PermissionCheckOptions {
|
export interface PermissionCheckOptions {
|
||||||
|
|||||||
@@ -90,41 +90,22 @@ class PlexScanner
|
|||||||
if (this.isRecentOnly) {
|
if (this.isRecentOnly) {
|
||||||
for (const library of this.libraries) {
|
for (const library of this.libraries) {
|
||||||
this.currentLibrary = library;
|
this.currentLibrary = library;
|
||||||
const addedAtOpt = library.lastScan
|
this.log(
|
||||||
|
`Beginning to process recently added for library: ${library.name}`,
|
||||||
|
'info',
|
||||||
|
{ lastScan: library.lastScan }
|
||||||
|
);
|
||||||
|
const libraryItems = await this.plexClient.getRecentlyAdded(
|
||||||
|
library.id,
|
||||||
|
library.lastScan
|
||||||
? {
|
? {
|
||||||
// We remove 10 minutes from the last scan as a buffer
|
// We remove 10 minutes from the last scan as a buffer
|
||||||
addedAt: library.lastScan - 1000 * 60 * 10,
|
addedAt: library.lastScan - 1000 * 60 * 10,
|
||||||
}
|
}
|
||||||
: undefined;
|
|
||||||
this.log(
|
|
||||||
`Beginning to process recently added for library: ${library.name}`,
|
|
||||||
'info',
|
|
||||||
{
|
|
||||||
lastScan: library.lastScan,
|
|
||||||
addedAtFilter: addedAtOpt?.addedAt,
|
|
||||||
addedAtFilterDate: addedAtOpt
|
|
||||||
? new Date(addedAtOpt.addedAt).toISOString()
|
|
||||||
: undefined,
|
: undefined,
|
||||||
}
|
|
||||||
);
|
|
||||||
const libraryItems = await this.plexClient.getRecentlyAdded(
|
|
||||||
library.id,
|
|
||||||
addedAtOpt ?? {
|
|
||||||
addedAt: Date.now() - 1000 * 60 * 60,
|
|
||||||
},
|
|
||||||
library.type
|
library.type
|
||||||
);
|
);
|
||||||
|
|
||||||
this.log(
|
|
||||||
`Recently added fetched ${libraryItems.length} items for library: ${library.name}`,
|
|
||||||
'debug',
|
|
||||||
{
|
|
||||||
libraryId: library.id,
|
|
||||||
itemCount: libraryItems.length,
|
|
||||||
lastScanWillUpdateTo: Date.now(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Bundle items up by rating keys
|
// Bundle items up by rating keys
|
||||||
this.items = uniqWith(libraryItems, (mediaA, mediaB) => {
|
this.items = uniqWith(libraryItems, (mediaA, mediaB) => {
|
||||||
if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) {
|
if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) {
|
||||||
|
|||||||
@@ -132,15 +132,15 @@ export interface MainSettings {
|
|||||||
tv: Quota;
|
tv: Quota;
|
||||||
};
|
};
|
||||||
hideAvailable: boolean;
|
hideAvailable: boolean;
|
||||||
hideBlocklisted: boolean;
|
hideBlacklisted: 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;
|
||||||
blocklistedTags: string;
|
blacklistedTags: string;
|
||||||
blocklistedTagsLimit: number;
|
blacklistedTagsLimit: 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;
|
||||||
hideBlocklisted: boolean;
|
hideBlacklisted: 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-blocklisted-tags';
|
| 'process-blacklisted-tags';
|
||||||
|
|
||||||
export interface AllSettings {
|
export interface AllSettings {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
@@ -389,15 +389,15 @@ class Settings {
|
|||||||
tv: {},
|
tv: {},
|
||||||
},
|
},
|
||||||
hideAvailable: false,
|
hideAvailable: false,
|
||||||
hideBlocklisted: false,
|
hideBlacklisted: false,
|
||||||
localLogin: true,
|
localLogin: true,
|
||||||
mediaServerLogin: true,
|
mediaServerLogin: true,
|
||||||
newPlexLogin: true,
|
newPlexLogin: true,
|
||||||
discoverRegion: '',
|
discoverRegion: '',
|
||||||
streamingRegion: '',
|
streamingRegion: '',
|
||||||
originalLanguage: '',
|
originalLanguage: '',
|
||||||
blocklistedTags: '',
|
blacklistedTags: '',
|
||||||
blocklistedTagsLimit: 50,
|
blacklistedTagsLimit: 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-blocklisted-tags': {
|
'process-blacklisted-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,
|
||||||
hideBlocklisted: this.data.main.hideBlocklisted,
|
hideBlacklisted: this.data.main.hideBlacklisted,
|
||||||
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,
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { AllSettings } from '@server/lib/settings';
|
|
||||||
|
|
||||||
const migrateBlacklistToBlocklist = (settings: any): AllSettings => {
|
|
||||||
if (
|
|
||||||
Array.isArray(settings.migrations) &&
|
|
||||||
settings.migrations.includes('0008_migrate_blacklist_to_blocklist')
|
|
||||||
) {
|
|
||||||
return settings;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.main?.hideBlacklisted !== undefined) {
|
|
||||||
settings.main.hideBlocklisted = settings.main.hideBlacklisted;
|
|
||||||
delete settings.main.hideBlacklisted;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.main?.blacklistedTags !== undefined) {
|
|
||||||
settings.main.blocklistedTags = settings.main.blacklistedTags;
|
|
||||||
delete settings.main.blacklistedTags;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.main?.blacklistedTagsLimit !== undefined) {
|
|
||||||
settings.main.blocklistedTagsLimit = settings.main.blacklistedTagsLimit;
|
|
||||||
delete settings.main.blacklistedTagsLimit;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.jobs?.['process-blacklisted-tags']) {
|
|
||||||
settings.jobs['process-blocklisted-tags'] =
|
|
||||||
settings.jobs['process-blacklisted-tags'];
|
|
||||||
delete settings.jobs['process-blacklisted-tags'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(settings.migrations)) {
|
|
||||||
settings.migrations = [];
|
|
||||||
}
|
|
||||||
settings.migrations.push('0008_migrate_blacklist_to_blocklist');
|
|
||||||
|
|
||||||
return settings;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default migrateBlacklistToBlocklist;
|
|
||||||
@@ -3,7 +3,7 @@ import { MediaStatus, MediaType } from '@server/constants/media';
|
|||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import {
|
import {
|
||||||
BlocklistedMediaError,
|
BlacklistedMediaError,
|
||||||
DuplicateMediaRequestError,
|
DuplicateMediaRequestError,
|
||||||
MediaRequest,
|
MediaRequest,
|
||||||
NoSeasonsAvailableError,
|
NoSeasonsAvailableError,
|
||||||
@@ -145,8 +145,8 @@ class WatchlistSync {
|
|||||||
errorMessage: e.message,
|
errorMessage: e.message,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
// Blocklisted media should be silently ignored during watchlist sync to avoid spam
|
// Blacklisted media should be silently ignored during watchlist sync to avoid spam
|
||||||
case BlocklistedMediaError:
|
case BlacklistedMediaError:
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
logger.error('Failed to create media request from watchlist', {
|
logger.error('Failed to create media request from watchlist', {
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import logger from '@server/logger';
|
|
||||||
import type { NextFunction, Request, Response } from 'express';
|
|
||||||
|
|
||||||
interface DeprecationOptions {
|
|
||||||
oldPath: string;
|
|
||||||
newPath: string;
|
|
||||||
sunsetDate?: string;
|
|
||||||
documentationUrl?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark an API route as deprecated.
|
|
||||||
* @see https://datatracker.ietf.org/doc/html/rfc8594
|
|
||||||
*/
|
|
||||||
export const deprecatedRoute = ({
|
|
||||||
oldPath,
|
|
||||||
newPath,
|
|
||||||
sunsetDate,
|
|
||||||
documentationUrl,
|
|
||||||
}: DeprecationOptions) => {
|
|
||||||
return (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
logger.warn(
|
|
||||||
`Deprecated API endpoint accessed: ${oldPath} → use ${newPath} instead`,
|
|
||||||
{
|
|
||||||
label: 'API Deprecation',
|
|
||||||
ip: req.ip,
|
|
||||||
userAgent: req.get('User-Agent'),
|
|
||||||
method: req.method,
|
|
||||||
path: req.originalUrl,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
res.setHeader('Deprecation', 'true');
|
|
||||||
|
|
||||||
const links: string[] = [`<${newPath}>; rel="successor-version"`];
|
|
||||||
if (documentationUrl) {
|
|
||||||
links.push(`<${documentationUrl}>; rel="deprecation"`);
|
|
||||||
}
|
|
||||||
res.setHeader('Link', links.join(', '));
|
|
||||||
|
|
||||||
if (sunsetDate) {
|
|
||||||
res.setHeader('Sunset', new Date(sunsetDate).toUTCString());
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default deprecatedRoute;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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 { Blocklist } from '@server/entity/Blocklist';
|
import { Blacklist } from '@server/entity/Blacklist';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import type { BlocklistResultsResponse } from '@server/interfaces/api/blocklistInterfaces';
|
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
|
||||||
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 blocklistRoutes = Router();
|
const blacklistRoutes = Router();
|
||||||
|
|
||||||
export const blocklistAdd = z.object({
|
export const blacklistAdd = 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 blocklistGet = z.object({
|
const blacklistGet = 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', 'blocklistedTags']).optional(),
|
filter: z.enum(['all', 'manual', 'blacklistedTags']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
blocklistRoutes.get(
|
blacklistRoutes.get(
|
||||||
'/',
|
'/',
|
||||||
isAuthenticated([Permission.MANAGE_BLOCKLIST, Permission.VIEW_BLOCKLIST], {
|
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
|
||||||
type: 'or',
|
type: 'or',
|
||||||
}),
|
}),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
const { take, skip, search, filter } = blocklistGet.parse(req.query);
|
const { take, skip, search, filter } = blacklistGet.parse(req.query);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let query = getRepository(Blocklist)
|
let query = getRepository(Blacklist)
|
||||||
.createQueryBuilder('blocklist')
|
.createQueryBuilder('blacklist')
|
||||||
.leftJoinAndSelect('blocklist.user', 'user')
|
.leftJoinAndSelect('blacklist.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('blocklist.blocklistedTags IS NULL');
|
query = query.andWhere('blacklist.blacklistedTags IS NULL');
|
||||||
break;
|
break;
|
||||||
case 'blocklistedTags':
|
case 'blacklistedTags':
|
||||||
query = query.andWhere('blocklist.blocklistedTags IS NOT NULL');
|
query = query.andWhere('blacklist.blacklistedTags IS NOT NULL');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
query = query.andWhere('blocklist.title like :title', {
|
query = query.andWhere('blacklist.title like :title', {
|
||||||
title: `%${search}%`,
|
title: `%${search}%`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const [blocklistedItems, itemsCount] = await query
|
const [blacklistedItems, itemsCount] = await query
|
||||||
.orderBy('blocklist.createdAt', 'DESC')
|
.orderBy('blacklist.createdAt', 'DESC')
|
||||||
.take(take)
|
.take(take)
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.getManyAndCount();
|
.getManyAndCount();
|
||||||
@@ -68,35 +68,35 @@ blocklistRoutes.get(
|
|||||||
results: itemsCount,
|
results: itemsCount,
|
||||||
page: Math.ceil(skip / take) + 1,
|
page: Math.ceil(skip / take) + 1,
|
||||||
},
|
},
|
||||||
results: blocklistedItems,
|
results: blacklistedItems,
|
||||||
} as BlocklistResultsResponse);
|
} as BlacklistResultsResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Something went wrong while retrieving blocklisted items', {
|
logger.error('Something went wrong while retrieving blacklisted items', {
|
||||||
label: 'Blocklist',
|
label: 'Blacklist',
|
||||||
errorMessage: error.message,
|
errorMessage: error.message,
|
||||||
});
|
});
|
||||||
return next({
|
return next({
|
||||||
status: 500,
|
status: 500,
|
||||||
message: 'Unable to retrieve blocklisted items.',
|
message: 'Unable to retrieve blacklisted items.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
blocklistRoutes.get(
|
blacklistRoutes.get(
|
||||||
'/:id',
|
'/:id',
|
||||||
isAuthenticated([Permission.MANAGE_BLOCKLIST], {
|
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
||||||
type: 'or',
|
type: 'or',
|
||||||
}),
|
}),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const blocklisteRepository = getRepository(Blocklist);
|
const blacklisteRepository = getRepository(Blacklist);
|
||||||
|
|
||||||
const blocklistItem = await blocklisteRepository.findOneOrFail({
|
const blacklistItem = await blacklisteRepository.findOneOrFail({
|
||||||
where: { tmdbId: Number(req.params.id) },
|
where: { tmdbId: Number(req.params.id) },
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(200).send(blocklistItem);
|
return res.status(200).send(blacklistItem);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof EntityNotFoundError) {
|
if (e instanceof EntityNotFoundError) {
|
||||||
return next({
|
return next({
|
||||||
@@ -109,17 +109,17 @@ blocklistRoutes.get(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
blocklistRoutes.post(
|
blacklistRoutes.post(
|
||||||
'/',
|
'/',
|
||||||
isAuthenticated([Permission.MANAGE_BLOCKLIST], {
|
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
||||||
type: 'or',
|
type: 'or',
|
||||||
}),
|
}),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const values = blocklistAdd.parse(req.body);
|
const values = blacklistAdd.parse(req.body);
|
||||||
|
|
||||||
await Blocklist.addToBlocklist({
|
await Blacklist.addToBlacklist({
|
||||||
blocklistRequest: values,
|
blacklistRequest: values,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(201).send();
|
return res.status(201).send();
|
||||||
@@ -131,12 +131,12 @@ blocklistRoutes.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 blocklisted' });
|
return next({ status: 412, message: 'Item already blacklisted' });
|
||||||
default:
|
default:
|
||||||
logger.warn('Something wrong with data blocklist', {
|
logger.warn('Something wrong with data blacklist', {
|
||||||
tmdbId: req.body.tmdbId,
|
tmdbId: req.body.tmdbId,
|
||||||
mediaType: req.body.mediaType,
|
mediaType: req.body.mediaType,
|
||||||
label: 'Blocklist',
|
label: 'Blacklist',
|
||||||
});
|
});
|
||||||
return next({ status: 409, message: 'Something wrong' });
|
return next({ status: 409, message: 'Something wrong' });
|
||||||
}
|
}
|
||||||
@@ -147,20 +147,20 @@ blocklistRoutes.post(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
blocklistRoutes.delete(
|
blacklistRoutes.delete(
|
||||||
'/:id',
|
'/:id',
|
||||||
isAuthenticated([Permission.MANAGE_BLOCKLIST], {
|
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
||||||
type: 'or',
|
type: 'or',
|
||||||
}),
|
}),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const blocklisteRepository = getRepository(Blocklist);
|
const blacklisteRepository = getRepository(Blacklist);
|
||||||
|
|
||||||
const blocklistItem = await blocklisteRepository.findOneOrFail({
|
const blacklistItem = await blacklisteRepository.findOneOrFail({
|
||||||
where: { tmdbId: Number(req.params.id) },
|
where: { tmdbId: Number(req.params.id) },
|
||||||
});
|
});
|
||||||
|
|
||||||
await blocklisteRepository.remove(blocklistItem);
|
await blacklisteRepository.remove(blacklistItem);
|
||||||
|
|
||||||
const mediaRepository = getRepository(Media);
|
const mediaRepository = getRepository(Media);
|
||||||
|
|
||||||
@@ -183,4 +183,4 @@ blocklistRoutes.delete(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export default blocklistRoutes;
|
export default blacklistRoutes;
|
||||||
@@ -12,7 +12,6 @@ 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';
|
||||||
@@ -29,7 +28,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 blocklistRoutes from './blocklist';
|
import blacklistRoutes from './blacklist';
|
||||||
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';
|
||||||
@@ -152,17 +151,7 @@ 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('/blocklist', isAuthenticated(), blocklistRoutes);
|
router.use('/blacklist', isAuthenticated(), blacklistRoutes);
|
||||||
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 {
|
||||||
BlocklistedMediaError,
|
BlacklistedMediaError,
|
||||||
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 BlocklistedMediaError:
|
case BlacklistedMediaError:
|
||||||
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 });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import BlocklistedTagsBadge from '@app/components/BlocklistedTagsBadge';
|
import BlacklistedTagsBadge from '@app/components/BlacklistedTagsBadge';
|
||||||
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 {
|
||||||
BlocklistItem,
|
BlacklistItem,
|
||||||
BlocklistResultsResponse,
|
BlacklistResultsResponse,
|
||||||
} from '@server/interfaces/api/blocklistInterfaces';
|
} from '@server/interfaces/api/blacklistInterfaces';
|
||||||
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.Blocklist', {
|
const messages = defineMessages('components.Blacklist', {
|
||||||
blocklistsettings: 'Blocklist Settings',
|
blacklistsettings: 'Blacklist Settings',
|
||||||
blocklistSettingsDescription: 'Manage blocklisted media.',
|
blacklistSettingsDescription: 'Manage blacklisted media.',
|
||||||
mediaName: 'Name',
|
mediaName: 'Name',
|
||||||
mediaType: 'Type',
|
mediaType: 'Type',
|
||||||
mediaTmdbId: 'tmdb Id',
|
mediaTmdbId: 'tmdb Id',
|
||||||
blocklistdate: 'date',
|
blacklistdate: 'date',
|
||||||
blocklistedby: '{date} by {user}',
|
blacklistedby: '{date} by {user}',
|
||||||
blocklistNotFoundError: '<strong>{title}</strong> is not blocklisted.',
|
blacklistNotFoundError: '<strong>{title}</strong> is not blacklisted.',
|
||||||
filterManual: 'Manual',
|
filterManual: 'Manual',
|
||||||
filterBlocklistedTags: 'Blocklisted Tags',
|
filterBlacklistedTags: 'Blacklisted Tags',
|
||||||
showAllBlocklisted: 'Show All Blocklisted Media',
|
showAllBlacklisted: 'Show All Blacklisted Media',
|
||||||
});
|
});
|
||||||
|
|
||||||
enum Filter {
|
enum Filter {
|
||||||
ALL = 'all',
|
ALL = 'all',
|
||||||
MANUAL = 'manual',
|
MANUAL = 'manual',
|
||||||
BLOCKLISTEDTAGS = 'blocklistedTags',
|
BLACKLISTEDTAGS = 'blacklistedTags',
|
||||||
}
|
}
|
||||||
|
|
||||||
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 Blocklist = () => {
|
const Blacklist = () => {
|
||||||
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 Blocklist = () => {
|
|||||||
data,
|
data,
|
||||||
error,
|
error,
|
||||||
mutate: revalidate,
|
mutate: revalidate,
|
||||||
} = useSWR<BlocklistResultsResponse>(
|
} = useSWR<BlacklistResultsResponse>(
|
||||||
`/api/v1/blocklist/?take=${currentPageSize}&skip=${
|
`/api/v1/blacklist/?take=${currentPageSize}&skip=${
|
||||||
pageIndex * currentPageSize
|
pageIndex * currentPageSize
|
||||||
}&filter=${currentFilter}${
|
}&filter=${currentFilter}${
|
||||||
debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''
|
debouncedSearchFilter ? `&search=${debouncedSearchFilter}` : ''
|
||||||
@@ -107,9 +107,9 @@ const Blocklist = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageTitle title={[intl.formatMessage(globalMessages.blocklist)]} />
|
<PageTitle title={[intl.formatMessage(globalMessages.blacklist)]} />
|
||||||
<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.blocklist)}</Header>
|
<Header>{intl.formatMessage(globalMessages.blacklist)}</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 Blocklist = () => {
|
|||||||
<option value="manual">
|
<option value="manual">
|
||||||
{intl.formatMessage(messages.filterManual)}
|
{intl.formatMessage(messages.filterManual)}
|
||||||
</option>
|
</option>
|
||||||
<option value="blocklistedTags">
|
<option value="blacklistedTags">
|
||||||
{intl.formatMessage(messages.filterBlocklistedTags)}
|
{intl.formatMessage(messages.filterBlacklistedTags)}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,16 +170,16 @@ const Blocklist = () => {
|
|||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
onClick={() => setCurrentFilter(Filter.ALL)}
|
onClick={() => setCurrentFilter(Filter.ALL)}
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.showAllBlocklisted)}
|
{intl.formatMessage(messages.showAllBlacklisted)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
data.results.map((item: BlocklistItem) => {
|
data.results.map((item: BlacklistItem) => {
|
||||||
return (
|
return (
|
||||||
<div className="py-2" key={`request-list-${item.tmdbId}`}>
|
<div className="py-2" key={`request-list-${item.tmdbId}`}>
|
||||||
<BlocklistedItem item={item} revalidateList={revalidate} />
|
<BlacklistedItem item={item} revalidateList={revalidate} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -260,14 +260,14 @@ const Blocklist = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Blocklist;
|
export default Blacklist;
|
||||||
|
|
||||||
interface BlocklistedItemProps {
|
interface BlacklistedItemProps {
|
||||||
item: BlocklistItem;
|
item: BlacklistItem;
|
||||||
revalidateList: () => void;
|
revalidateList: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BlocklistedItem = ({ item, revalidateList }: BlocklistedItemProps) => {
|
const BlacklistedItem = ({ item, revalidateList }: BlacklistedItemProps) => {
|
||||||
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 BlocklistedItem = ({ item, revalidateList }: BlocklistedItemProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeFromBlocklist = async (tmdbId: number, title?: string) => {
|
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.delete(`/api/v1/blocklist/${tmdbId}`);
|
await axios.delete(`/api/v1/blacklist/${tmdbId}`);
|
||||||
|
|
||||||
addToast(
|
addToast(
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(globalMessages.removeFromBlocklistSuccess, {
|
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
|
||||||
title,
|
title,
|
||||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||||
})}
|
})}
|
||||||
@@ -309,7 +309,7 @@ const BlocklistedItem = ({ item, revalidateList }: BlocklistedItemProps) => {
|
|||||||
{ appearance: 'success', autoDismiss: true }
|
{ appearance: 'success', autoDismiss: true }
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
addToast(intl.formatMessage(globalMessages.blocklistError), {
|
addToast(intl.formatMessage(globalMessages.blacklistError), {
|
||||||
appearance: 'error',
|
appearance: 'error',
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
});
|
});
|
||||||
@@ -389,17 +389,17 @@ const BlocklistedItem = ({ item, revalidateList }: BlocklistedItemProps) => {
|
|||||||
<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.blocklisted)}
|
{intl.formatMessage(globalMessages.blacklisted)}
|
||||||
</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.blocklisted)}
|
{intl.formatMessage(globalMessages.blacklisted)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex truncate text-sm text-gray-300">
|
<span className="flex truncate text-sm text-gray-300">
|
||||||
{intl.formatMessage(messages.blocklistedby, {
|
{intl.formatMessage(messages.blacklistedby, {
|
||||||
date: (
|
date: (
|
||||||
<FormattedRelativeTime
|
<FormattedRelativeTime
|
||||||
value={Math.floor(
|
value={Math.floor(
|
||||||
@@ -426,9 +426,9 @@ const BlocklistedItem = ({ item, revalidateList }: BlocklistedItemProps) => {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
) : item.blocklistedTags ? (
|
) : item.blacklistedTags ? (
|
||||||
<span className="ml-1">
|
<span className="ml-1">
|
||||||
<BlocklistedTagsBadge data={item} />
|
<BlacklistedTagsBadge 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 BlocklistedItem = ({ item, revalidateList }: BlocklistedItemProps) => {
|
|||||||
</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_BLOCKLIST) && (
|
{hasPermission(Permission.MANAGE_BLACKLIST) && (
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
removeFromBlocklist(
|
removeFromBlacklist(
|
||||||
item.tmdbId,
|
item.tmdbId,
|
||||||
title && (isMovie(title) ? title.title : title.name)
|
title && (isMovie(title) ? title.title : title.name)
|
||||||
)
|
)
|
||||||
@@ -474,7 +474,7 @@ const BlocklistedItem = ({ item, revalidateList }: BlocklistedItemProps) => {
|
|||||||
>
|
>
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(globalMessages.removefromBlocklist)}
|
{intl.formatMessage(globalMessages.removefromBlacklist)}
|
||||||
</span>
|
</span>
|
||||||
</ConfirmButton>
|
</ConfirmButton>
|
||||||
)}
|
)}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import BlocklistedTagsBadge from '@app/components/BlocklistedTagsBadge';
|
import BlacklistedTagsBadge from '@app/components/BlacklistedTagsBadge';
|
||||||
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 { Blocklist } from '@server/entity/Blocklist';
|
import type { Blacklist } from '@server/entity/Blacklist';
|
||||||
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.BlocklistBlock', {
|
const messages = defineMessages('component.BlacklistBlock', {
|
||||||
blocklistedby: 'Blocklisted By',
|
blacklistedby: 'Blacklisted By',
|
||||||
blocklistdate: 'Blocklisted date',
|
blacklistdate: 'Blacklisted date',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface BlocklistBlockProps {
|
interface BlacklistBlockProps {
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
onUpdate?: () => void;
|
onUpdate?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BlocklistBlock = ({
|
const BlacklistBlock = ({
|
||||||
tmdbId,
|
tmdbId,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: BlocklistBlockProps) => {
|
}: BlacklistBlockProps) => {
|
||||||
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<Blocklist>(`/api/v1/blocklist/${tmdbId}`);
|
const { data } = useSWR<Blacklist>(`/api/v1/blacklist/${tmdbId}`);
|
||||||
|
|
||||||
const removeFromBlocklist = async (tmdbId: number, title?: string) => {
|
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.delete('/api/v1/blocklist/' + tmdbId);
|
await axios.delete('/api/v1/blacklist/' + tmdbId);
|
||||||
|
|
||||||
addToast(
|
addToast(
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(globalMessages.removeFromBlocklistSuccess, {
|
{intl.formatMessage(globalMessages.removeFromBlacklistSuccess, {
|
||||||
title,
|
title,
|
||||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||||
})}
|
})}
|
||||||
@@ -53,7 +53,7 @@ const BlocklistBlock = ({
|
|||||||
{ appearance: 'success', autoDismiss: true }
|
{ appearance: 'success', autoDismiss: true }
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
addToast(intl.formatMessage(globalMessages.blocklistError), {
|
addToast(intl.formatMessage(globalMessages.blacklistError), {
|
||||||
appearance: 'error',
|
appearance: 'error',
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
});
|
});
|
||||||
@@ -80,7 +80,7 @@ const BlocklistBlock = ({
|
|||||||
<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.blocklistedby)}>
|
<Tooltip content={intl.formatMessage(messages.blacklistedby)}>
|
||||||
<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 BlocklistBlock = ({
|
|||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : data.blocklistedTags ? (
|
) : data.blacklistedTags ? (
|
||||||
<>
|
<>
|
||||||
<span className="w-40 truncate md:w-auto">
|
<span className="w-40 truncate md:w-auto">
|
||||||
{intl.formatMessage(messages.blocklistedby)}:
|
{intl.formatMessage(messages.blacklistedby)}:
|
||||||
</span>
|
</span>
|
||||||
<BlocklistedTagsBadge data={data} />
|
<BlacklistedTagsBadge 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.removefromBlocklist)}
|
content={intl.formatMessage(globalMessages.removefromBlacklist)}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
buttonType="danger"
|
buttonType="danger"
|
||||||
onClick={() => removeFromBlocklist(data.tmdbId, data.title)}
|
onClick={() => removeFromBlacklist(data.tmdbId, data.title)}
|
||||||
disabled={isUpdating}
|
disabled={isUpdating}
|
||||||
>
|
>
|
||||||
<TrashIcon className="icon-sm" />
|
<TrashIcon className="icon-sm" />
|
||||||
@@ -125,12 +125,12 @@ const BlocklistBlock = ({
|
|||||||
<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.blocklisted)}
|
{intl.formatMessage(globalMessages.blacklisted)}
|
||||||
</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.blocklistdate)}>
|
<Tooltip content={intl.formatMessage(messages.blacklistdate)}>
|
||||||
<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 BlocklistBlock = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default BlocklistBlock;
|
export default BlacklistBlock;
|
||||||
@@ -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 BlocklistModalProps {
|
interface BlacklistModalProps {
|
||||||
tmdbId: number;
|
tmdbId: number;
|
||||||
type: 'movie' | 'tv' | 'collection';
|
type: 'movie' | 'tv' | 'collection';
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -17,8 +17,8 @@ interface BlocklistModalProps {
|
|||||||
isUpdating?: boolean;
|
isUpdating?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = defineMessages('component.BlocklistModal', {
|
const messages = defineMessages('component.BlacklistModal', {
|
||||||
blocklisting: 'Blocklisting',
|
blacklisting: 'Blacklisting',
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMovie = (
|
const isMovie = (
|
||||||
@@ -28,14 +28,14 @@ const isMovie = (
|
|||||||
return (movie as MovieDetails).title !== undefined;
|
return (movie as MovieDetails).title !== undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BlocklistModal = ({
|
const BlacklistModal = ({
|
||||||
tmdbId,
|
tmdbId,
|
||||||
type,
|
type,
|
||||||
show,
|
show,
|
||||||
onComplete,
|
onComplete,
|
||||||
onCancel,
|
onCancel,
|
||||||
isUpdating,
|
isUpdating,
|
||||||
}: BlocklistModalProps) => {
|
}: BlacklistModalProps) => {
|
||||||
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 BlocklistModal = ({
|
|||||||
<Modal
|
<Modal
|
||||||
loading={!data && !error}
|
loading={!data && !error}
|
||||||
backgroundClickable
|
backgroundClickable
|
||||||
title={`${intl.formatMessage(globalMessages.blocklist)} ${
|
title={`${intl.formatMessage(globalMessages.blacklist)} ${
|
||||||
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 BlocklistModal = ({
|
|||||||
onOk={onComplete}
|
onOk={onComplete}
|
||||||
okText={
|
okText={
|
||||||
isUpdating
|
isUpdating
|
||||||
? intl.formatMessage(messages.blocklisting)
|
? intl.formatMessage(messages.blacklisting)
|
||||||
: intl.formatMessage(globalMessages.blocklist)
|
: intl.formatMessage(globalMessages.blacklist)
|
||||||
}
|
}
|
||||||
okButtonType="danger"
|
okButtonType="danger"
|
||||||
okDisabled={isUpdating}
|
okDisabled={isUpdating}
|
||||||
@@ -88,4 +88,4 @@ const BlocklistModal = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default BlocklistModal;
|
export default BlacklistModal;
|
||||||
@@ -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 { BlocklistItem } from '@server/interfaces/api/blocklistInterfaces';
|
import type { BlacklistItem } from '@server/interfaces/api/blacklistInterfaces';
|
||||||
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', {
|
||||||
blocklistedTagsText: 'Blocklisted Tags',
|
blacklistedTagsText: 'Blacklisted Tags',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface BlocklistedTagsBadgeProps {
|
interface BlacklistedTagsBadgeProps {
|
||||||
data: BlocklistItem;
|
data: BlacklistItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BlocklistedTagsBadge = ({ data }: BlocklistedTagsBadgeProps) => {
|
const BlacklistedTagsBadge = ({ data }: BlacklistedTagsBadgeProps) => {
|
||||||
const [tagNamesBlocklistedFor, setTagNamesBlocklistedFor] =
|
const [tagNamesBlacklistedFor, setTagNamesBlacklistedFor] =
|
||||||
useState<string>('Loading...');
|
useState<string>('Loading...');
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data.blocklistedTags) {
|
if (!data.blacklistedTags) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keywordIds = data.blocklistedTags.slice(1, -1).split(',');
|
const keywordIds = data.blacklistedTags.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 BlocklistedTagsBadge = ({ data }: BlocklistedTagsBadgeProps) => {
|
|||||||
return data?.name || `[Invalid: ${keywordId}]`;
|
return data?.name || `[Invalid: ${keywordId}]`;
|
||||||
})
|
})
|
||||||
).then((keywords) => {
|
).then((keywords) => {
|
||||||
setTagNamesBlocklistedFor(keywords.join(', '));
|
setTagNamesBlacklistedFor(keywords.join(', '));
|
||||||
});
|
});
|
||||||
}, [data.blocklistedTags]);
|
}, [data.blacklistedTags]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={tagNamesBlocklistedFor}
|
content={tagNamesBlacklistedFor}
|
||||||
tooltipConfig={{ followCursor: false }}
|
tooltipConfig={{ followCursor: false }}
|
||||||
>
|
>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -49,10 +49,10 @@ const BlocklistedTagsBadge = ({ data }: BlocklistedTagsBadgeProps) => {
|
|||||||
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.blocklistedTagsText)}
|
{intl.formatMessage(messages.blacklistedTagsText)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default BlocklistedTagsBadge;
|
export default BlacklistedTagsBadge;
|
||||||
@@ -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', {
|
||||||
copyBlocklistedTags: 'Copied blocklisted tags to clipboard.',
|
copyBlacklistedTags: 'Copied blacklisted tags to clipboard.',
|
||||||
copyBlocklistedTagsTip: 'Copy blocklisted tag configuration',
|
copyBlacklistedTagsTip: 'Copy blacklisted tag configuration',
|
||||||
copyBlocklistedTagsEmpty: 'Nothing to copy',
|
copyBlacklistedTagsEmpty: 'Nothing to copy',
|
||||||
importBlocklistedTagsTip: 'Import blocklisted tag configuration',
|
importBlacklistedTagsTip: 'Import blacklisted tag configuration',
|
||||||
clearBlocklistedTagsConfirm:
|
clearBlacklistedTagsConfirm:
|
||||||
'Are you sure you want to clear the blocklisted tags?',
|
'Are you sure you want to clear the blacklisted 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.',
|
||||||
blocklistedTagImportTitle: 'Import Blocklisted Tag Configuration',
|
blacklistedTagImportTitle: 'Import Blacklisted Tag Configuration',
|
||||||
blocklistedTagImportInstructions: 'Paste blocklist tag configuration below.',
|
blacklistedTagImportInstructions: 'Paste blacklist 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 BlocklistedTagsSelectorProps = {
|
type BlacklistedTagsSelectorProps = {
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BlocklistedTagsSelector = ({
|
const BlacklistedTagsSelector = ({
|
||||||
defaultValue,
|
defaultValue,
|
||||||
}: BlocklistedTagsSelectorProps) => {
|
}: BlacklistedTagsSelectorProps) => {
|
||||||
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 BlocklistedTagsSelector = ({
|
|||||||
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('blocklistedTags', strVal);
|
setFieldValue('blacklistedTags', strVal);
|
||||||
},
|
},
|
||||||
[setSelectorValue, setValue, setFieldValue]
|
[setSelectorValue, setValue, setFieldValue]
|
||||||
);
|
);
|
||||||
@@ -91,15 +91,15 @@ const BlocklistedTagsSelector = ({
|
|||||||
<CopyButton
|
<CopyButton
|
||||||
textToCopy={value ?? ''}
|
textToCopy={value ?? ''}
|
||||||
disabled={copyDisabled}
|
disabled={copyDisabled}
|
||||||
toastMessage={intl.formatMessage(messages.copyBlocklistedTags)}
|
toastMessage={intl.formatMessage(messages.copyBlacklistedTags)}
|
||||||
tooltipContent={intl.formatMessage(
|
tooltipContent={intl.formatMessage(
|
||||||
copyDisabled
|
copyDisabled
|
||||||
? messages.copyBlocklistedTagsEmpty
|
? messages.copyBlacklistedTagsEmpty
|
||||||
: messages.copyBlocklistedTagsTip
|
: messages.copyBlacklistedTagsTip
|
||||||
)}
|
)}
|
||||||
tooltipConfig={{ followCursor: false }}
|
tooltipConfig={{ followCursor: false }}
|
||||||
/>
|
/>
|
||||||
<BlocklistedTagsImportButton setSelector={update} />
|
<BlacklistedTagsImportButton setSelector={update} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -162,7 +162,7 @@ const ControlledKeywordSelector = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AsyncSelect
|
<AsyncSelect
|
||||||
key={`keyword-select-blocklistedTags`}
|
key={`keyword-select-blacklistedTags`}
|
||||||
inputId="data"
|
inputId="data"
|
||||||
isMulti
|
isMulti
|
||||||
className="react-select-container"
|
className="react-select-container"
|
||||||
@@ -181,13 +181,13 @@ const ControlledKeywordSelector = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type BlocklistedTagsImportButtonProps = {
|
type BlacklistedTagsImportButtonProps = {
|
||||||
setSelector: (value: MultiValue<SingleVal>) => void;
|
setSelector: (value: MultiValue<SingleVal>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BlocklistedTagsImportButton = ({
|
const BlacklistedTagsImportButton = ({
|
||||||
setSelector,
|
setSelector,
|
||||||
}: BlocklistedTagsImportButtonProps) => {
|
}: BlacklistedTagsImportButtonProps) => {
|
||||||
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 BlocklistedTagsImportButton = ({
|
|||||||
show={show}
|
show={show}
|
||||||
>
|
>
|
||||||
<Modal
|
<Modal
|
||||||
title={intl.formatMessage(messages.blocklistedTagImportTitle)}
|
title={intl.formatMessage(messages.blacklistedTagImportTitle)}
|
||||||
okText="Confirm"
|
okText="Confirm"
|
||||||
onOk={onConfirm}
|
onOk={onConfirm}
|
||||||
onCancel={() => setShow(false)}
|
onCancel={() => setShow(false)}
|
||||||
>
|
>
|
||||||
<BlocklistedTagImportForm ref={formRef} setSelector={setSelector} />
|
<BlacklistedTagImportForm ref={formRef} setSelector={setSelector} />
|
||||||
</Modal>
|
</Modal>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={intl.formatMessage(messages.importBlocklistedTagsTip)}
|
content={intl.formatMessage(messages.importBlacklistedTagsTip)}
|
||||||
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 BlocklistedTagsImportButton = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type BlocklistedTagImportFormProps = BlocklistedTagsImportButtonProps;
|
type BlacklistedTagImportFormProps = BlacklistedTagsImportButtonProps;
|
||||||
|
|
||||||
const BlocklistedTagImportForm = forwardRef<
|
const BlacklistedTagImportForm = forwardRef<
|
||||||
Partial<HTMLFormElement>,
|
Partial<HTMLFormElement>,
|
||||||
BlocklistedTagImportFormProps
|
BlacklistedTagImportFormProps
|
||||||
>((props, ref) => {
|
>((props, ref) => {
|
||||||
const { setSelector } = props;
|
const { setSelector } = props;
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -306,7 +306,7 @@ const BlocklistedTagImportForm = forwardRef<
|
|||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="value">
|
<label htmlFor="value">
|
||||||
{intl.formatMessage(messages.blocklistedTagImportInstructions)}
|
{intl.formatMessage(messages.blacklistedTagImportInstructions)}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="value"
|
id="value"
|
||||||
@@ -392,7 +392,7 @@ const VerifyClearIndicator = <
|
|||||||
show={show}
|
show={show}
|
||||||
>
|
>
|
||||||
<Modal
|
<Modal
|
||||||
subTitle={intl.formatMessage(messages.clearBlocklistedTagsConfirm)}
|
subTitle={intl.formatMessage(messages.clearBlacklistedTagsConfirm)}
|
||||||
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 BlocklistedTagsSelector;
|
export default BlacklistedTagsSelector;
|
||||||
@@ -188,8 +188,8 @@ const CollectionDetails = ({ collection }: CollectionDetailsProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blocklistVisibility = hasPermission(
|
const blacklistVisibility = hasPermission(
|
||||||
[Permission.MANAGE_BLOCKLIST, Permission.VIEW_BLOCKLIST],
|
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
|
||||||
{ 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 (!blocklistVisibility)
|
if (!blacklistVisibility)
|
||||||
return title.mediaInfo?.status !== MediaStatus.BLOCKLISTED;
|
return title.mediaInfo?.status !== MediaStatus.BLACKLISTED;
|
||||||
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 blocklistVisibility = hasPermission(
|
const blacklistVisibility = hasPermission(
|
||||||
[Permission.MANAGE_BLOCKLIST, Permission.VIEW_BLOCKLIST],
|
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
|
||||||
{ type: 'or' }
|
{ type: 'or' }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -66,10 +66,10 @@ const ListView = ({
|
|||||||
})}
|
})}
|
||||||
{items
|
{items
|
||||||
?.filter((title) => {
|
?.filter((title) => {
|
||||||
if (!blocklistVisibility)
|
if (!blacklistVisibility)
|
||||||
return (
|
return (
|
||||||
(title as TvResult | MovieResult).mediaInfo?.status !==
|
(title as TvResult | MovieResult).mediaInfo?.status !==
|
||||||
MediaStatus.BLOCKLISTED
|
MediaStatus.BLACKLISTED
|
||||||
);
|
);
|
||||||
return title;
|
return title;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const StatusBadgeMini = ({
|
|||||||
);
|
);
|
||||||
indicatorIcon = <BellIcon />;
|
indicatorIcon = <BellIcon />;
|
||||||
break;
|
break;
|
||||||
case MediaStatus.BLOCKLISTED:
|
case MediaStatus.BLACKLISTED:
|
||||||
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: '/blocklist',
|
href: '/blacklist',
|
||||||
content: intl.formatMessage(menuMessages.blocklist),
|
content: intl.formatMessage(menuMessages.blacklist),
|
||||||
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: /^\/blocklist/,
|
activeRegExp: /^\/blacklist/,
|
||||||
requiredPermission: [
|
requiredPermission: [
|
||||||
Permission.MANAGE_BLOCKLIST,
|
Permission.MANAGE_BLACKLIST,
|
||||||
Permission.VIEW_BLOCKLIST,
|
Permission.VIEW_BLACKLIST,
|
||||||
],
|
],
|
||||||
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',
|
||||||
blocklist: 'Blocklist',
|
blacklist: 'Blacklist',
|
||||||
issues: 'Issues',
|
issues: 'Issues',
|
||||||
users: 'Users',
|
users: 'Users',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
@@ -79,13 +79,13 @@ const SidebarLinks: SidebarLinkProps[] = [
|
|||||||
activeRegExp: /^\/requests/,
|
activeRegExp: /^\/requests/,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/blocklist',
|
href: '/blacklist',
|
||||||
messagesKey: 'blocklist',
|
messagesKey: 'blacklist',
|
||||||
svgIcon: <EyeSlashIcon className="mr-3 h-6 w-6" />,
|
svgIcon: <EyeSlashIcon className="mr-3 h-6 w-6" />,
|
||||||
activeRegExp: /^\/blocklist/,
|
activeRegExp: /^\/blacklist/,
|
||||||
requiredPermission: [
|
requiredPermission: [
|
||||||
Permission.MANAGE_BLOCKLIST,
|
Permission.MANAGE_BLACKLIST,
|
||||||
Permission.VIEW_BLOCKLIST,
|
Permission.VIEW_BLACKLIST,
|
||||||
],
|
],
|
||||||
permissionType: 'or',
|
permissionType: 'or',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import BlocklistBlock from '@app/components/BlocklistBlock';
|
import BlacklistBlock from '@app/components/BlacklistBlock';
|
||||||
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.BLOCKLISTED && (
|
{data.mediaInfo?.status === MediaStatus.BLACKLISTED && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 text-xl font-bold">
|
<h3 className="mb-2 text-xl font-bold">
|
||||||
{intl.formatMessage(globalMessages.blocklist)}
|
{intl.formatMessage(globalMessages.blacklist)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
|
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
|
||||||
<BlocklistBlock
|
<BlacklistBlock
|
||||||
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.BLOCKLISTED && (
|
data.mediaInfo.status !== MediaStatus.BLACKLISTED && (
|
||||||
<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.hideBlocklisted) {
|
if (settings.currentSettings.hideBlacklisted) {
|
||||||
titles = titles.filter(
|
titles = titles.filter(
|
||||||
(i) =>
|
(i) =>
|
||||||
(i.mediaType === 'movie' || i.mediaType === 'tv') &&
|
(i.mediaType === 'movie' || i.mediaType === 'tv') &&
|
||||||
i.mediaInfo?.status !== MediaStatus.BLOCKLISTED
|
i.mediaInfo?.status !== MediaStatus.BLACKLISTED
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,18 +102,18 @@ const MediaSlider = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blocklistVisibility = hasPermission(
|
const blacklistVisibility = hasPermission(
|
||||||
[Permission.MANAGE_BLOCKLIST, Permission.VIEW_BLOCKLIST],
|
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
|
||||||
{ type: 'or' }
|
{ type: 'or' }
|
||||||
);
|
);
|
||||||
|
|
||||||
const finalTitles = titles
|
const finalTitles = titles
|
||||||
.slice(0, 20)
|
.slice(0, 20)
|
||||||
.filter((title) => {
|
.filter((title) => {
|
||||||
if (!blocklistVisibility)
|
if (!blacklistVisibility)
|
||||||
return (
|
return (
|
||||||
(title as TvResult | MovieResult).mediaInfo?.status !==
|
(title as TvResult | MovieResult).mediaInfo?.status !==
|
||||||
MediaStatus.BLOCKLISTED
|
MediaStatus.BLACKLISTED
|
||||||
);
|
);
|
||||||
return title;
|
return title;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import RTRotten from '@app/assets/rt_rotten.svg';
|
|||||||
import ImdbLogo from '@app/assets/services/imdb.svg';
|
import ImdbLogo from '@app/assets/services/imdb.svg';
|
||||||
import Spinner from '@app/assets/spinner.svg';
|
import Spinner from '@app/assets/spinner.svg';
|
||||||
import TmdbLogo from '@app/assets/tmdb_logo.svg';
|
import TmdbLogo from '@app/assets/tmdb_logo.svg';
|
||||||
import BlocklistModal from '@app/components/BlocklistModal';
|
import BlacklistModal from '@app/components/BlacklistModal';
|
||||||
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 LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||||
@@ -128,9 +128,9 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
|
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
|
||||||
!movie?.onUserWatchlist
|
!movie?.onUserWatchlist
|
||||||
);
|
);
|
||||||
const [isBlocklistUpdating, setIsBlocklistUpdating] =
|
const [isBlacklistUpdating, setIsBlacklistUpdating] =
|
||||||
useState<boolean>(false);
|
useState<boolean>(false);
|
||||||
const [showBlocklistModal, setShowBlocklistModal] = useState(false);
|
const [showBlacklistModal, setShowBlacklistModal] = useState(false);
|
||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -161,8 +161,8 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
setShowManager(router.query.manage == '1' ? true : false);
|
setShowManager(router.query.manage == '1' ? true : false);
|
||||||
}, [router.query.manage]);
|
}, [router.query.manage]);
|
||||||
|
|
||||||
const closeBlocklistModal = useCallback(
|
const closeBlacklistModal = useCallback(
|
||||||
() => setShowBlocklistModal(false),
|
() => setShowBlacklistModal(false),
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -381,10 +381,10 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onClickHideItemBtn = async (): Promise<void> => {
|
const onClickHideItemBtn = async (): Promise<void> => {
|
||||||
setIsBlocklistUpdating(true);
|
setIsBlacklistUpdating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/v1/blocklist', {
|
await axios.post('/api/v1/blacklist', {
|
||||||
tmdbId: movie?.id,
|
tmdbId: movie?.id,
|
||||||
mediaType: 'movie',
|
mediaType: 'movie',
|
||||||
title: movie?.title,
|
title: movie?.title,
|
||||||
@@ -393,7 +393,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
|
|
||||||
addToast(
|
addToast(
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(globalMessages.blocklistSuccess, {
|
{intl.formatMessage(globalMessages.blacklistSuccess, {
|
||||||
title: movie?.title,
|
title: movie?.title,
|
||||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||||
})}
|
})}
|
||||||
@@ -406,7 +406,7 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
if (e?.response?.status === 412) {
|
if (e?.response?.status === 412) {
|
||||||
addToast(
|
addToast(
|
||||||
<span>
|
<span>
|
||||||
{intl.formatMessage(globalMessages.blocklistDuplicateError, {
|
{intl.formatMessage(globalMessages.blacklistDuplicateError, {
|
||||||
title: movie?.title,
|
title: movie?.title,
|
||||||
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
strong: (msg: React.ReactNode) => <strong>{msg}</strong>,
|
||||||
})}
|
})}
|
||||||
@@ -414,18 +414,18 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
{ appearance: 'info', autoDismiss: true }
|
{ appearance: 'info', autoDismiss: true }
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
addToast(intl.formatMessage(globalMessages.blocklistError), {
|
addToast(intl.formatMessage(globalMessages.blacklistError), {
|
||||||
appearance: 'error',
|
appearance: 'error',
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsBlocklistUpdating(false);
|
setIsBlacklistUpdating(false);
|
||||||
closeBlocklistModal();
|
closeBlacklistModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const showHideButton = hasPermission([Permission.MANAGE_BLOCKLIST], {
|
const showHideButton = hasPermission([Permission.MANAGE_BLACKLIST], {
|
||||||
type: 'or',
|
type: 'or',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -475,13 +475,13 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
revalidate={() => revalidate()}
|
revalidate={() => revalidate()}
|
||||||
show={showManager}
|
show={showManager}
|
||||||
/>
|
/>
|
||||||
<BlocklistModal
|
<BlacklistModal
|
||||||
tmdbId={data.id}
|
tmdbId={data.id}
|
||||||
type="movie"
|
type="movie"
|
||||||
show={showBlocklistModal}
|
show={showBlacklistModal}
|
||||||
onCancel={closeBlocklistModal}
|
onCancel={closeBlacklistModal}
|
||||||
onComplete={onClickHideItemBtn}
|
onComplete={onClickHideItemBtn}
|
||||||
isUpdating={isBlocklistUpdating}
|
isUpdating={isBlacklistUpdating}
|
||||||
/>
|
/>
|
||||||
<div className="media-header">
|
<div className="media-header">
|
||||||
<div className="media-poster">
|
<div className="media-poster">
|
||||||
@@ -565,21 +565,21 @@ const MovieDetails = ({ movie }: MovieDetailsProps) => {
|
|||||||
data?.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
data?.mediaInfo?.status !== MediaStatus.AVAILABLE &&
|
||||||
data?.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE &&
|
data?.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE &&
|
||||||
data?.mediaInfo?.status !== MediaStatus.PENDING &&
|
data?.mediaInfo?.status !== MediaStatus.PENDING &&
|
||||||
data?.mediaInfo?.status !== MediaStatus.BLOCKLISTED && (
|
data?.mediaInfo?.status !== MediaStatus.BLACKLISTED && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={intl.formatMessage(globalMessages.addToBlocklist)}
|
content={intl.formatMessage(globalMessages.addToBlacklist)}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
buttonType={'ghost'}
|
buttonType={'ghost'}
|
||||||
className="z-40 mr-2"
|
className="z-40 mr-2"
|
||||||
buttonSize={'md'}
|
buttonSize={'md'}
|
||||||
onClick={() => setShowBlocklistModal(true)}
|
onClick={() => setShowBlacklistModal(true)}
|
||||||
>
|
>
|
||||||
<EyeSlashIcon />
|
<EyeSlashIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{data?.mediaInfo?.status !== MediaStatus.BLOCKLISTED &&
|
{data?.mediaInfo?.status !== MediaStatus.BLACKLISTED &&
|
||||||
user?.userType !== UserType.PLEX && (
|
user?.userType !== UserType.PLEX && (
|
||||||
<>
|
<>
|
||||||
{toggleWatchlist ? (
|
{toggleWatchlist ? (
|
||||||
|
|||||||
@@ -78,13 +78,13 @@ export const messages = defineMessages('components.PermissionEdit', {
|
|||||||
viewwatchlists: 'View {mediaServerName} Watchlists',
|
viewwatchlists: 'View {mediaServerName} Watchlists',
|
||||||
viewwatchlistsDescription:
|
viewwatchlistsDescription:
|
||||||
"Grant permission to view other users' {mediaServerName} Watchlists.",
|
"Grant permission to view other users' {mediaServerName} Watchlists.",
|
||||||
manageblocklist: 'Manage Blocklist',
|
manageblacklist: 'Manage Blacklist',
|
||||||
manageblocklistDescription: 'Grant permission to manage blocklisted media.',
|
manageblacklistDescription: 'Grant permission to manage blacklisted media.',
|
||||||
blocklistedItems: 'Blocklist media.',
|
blacklistedItems: 'Blacklist media.',
|
||||||
blocklistedItemsDescription: 'Grant permission to blocklist media.',
|
blacklistedItemsDescription: 'Grant permission to blacklist media.',
|
||||||
viewblocklistedItems: 'View blocklisted media.',
|
viewblacklistedItems: 'View blacklisted media.',
|
||||||
viewblocklistedItemsDescription:
|
viewblacklistedItemsDescription:
|
||||||
'Grant permission to view blocklisted media.',
|
'Grant permission to view blacklisted media.',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface PermissionEditProps {
|
interface PermissionEditProps {
|
||||||
@@ -340,18 +340,18 @@ export const PermissionEdit = ({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'manageblocklist',
|
id: 'manageblacklist',
|
||||||
name: intl.formatMessage(messages.manageblocklist),
|
name: intl.formatMessage(messages.manageblacklist),
|
||||||
description: intl.formatMessage(messages.manageblocklistDescription),
|
description: intl.formatMessage(messages.manageblacklistDescription),
|
||||||
permission: Permission.MANAGE_BLOCKLIST,
|
permission: Permission.MANAGE_BLACKLIST,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 'viewblocklisteditems',
|
id: 'viewblacklisteditems',
|
||||||
name: intl.formatMessage(messages.viewblocklistedItems),
|
name: intl.formatMessage(messages.viewblacklistedItems),
|
||||||
description: intl.formatMessage(
|
description: intl.formatMessage(
|
||||||
messages.viewblocklistedItemsDescription
|
messages.viewblacklistedItemsDescription
|
||||||
),
|
),
|
||||||
permission: Permission.VIEW_BLOCKLIST,
|
permission: Permission.VIEW_BLACKLIST,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ const RequestButton = ({
|
|||||||
type: 'or',
|
type: 'or',
|
||||||
}) &&
|
}) &&
|
||||||
media &&
|
media &&
|
||||||
media.status !== MediaStatus.BLOCKLISTED &&
|
media.status !== MediaStatus.BLACKLISTED &&
|
||||||
!isShowComplete
|
!isShowComplete
|
||||||
) {
|
) {
|
||||||
buttons.push({
|
buttons.push({
|
||||||
@@ -345,7 +345,7 @@ const RequestButton = ({
|
|||||||
type: 'or',
|
type: 'or',
|
||||||
}) &&
|
}) &&
|
||||||
media &&
|
media &&
|
||||||
media.status4k !== MediaStatus.BLOCKLISTED &&
|
media.status4k !== MediaStatus.BLACKLISTED &&
|
||||||
!is4kShowComplete &&
|
!is4kShowComplete &&
|
||||||
settings.currentSettings.series4kEnabled
|
settings.currentSettings.series4kEnabled
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const CollectionRequestModal = ({
|
|||||||
|
|
||||||
const getAllParts = (): number[] => {
|
const getAllParts = (): number[] => {
|
||||||
return (data?.parts ?? [])
|
return (data?.parts ?? [])
|
||||||
.filter((part) => part.mediaInfo?.status !== MediaStatus.BLOCKLISTED)
|
.filter((part) => part.mediaInfo?.status !== MediaStatus.BLACKLISTED)
|
||||||
.map((part) => part.id);
|
.map((part) => part.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -257,8 +257,8 @@ const CollectionRequestModal = ({
|
|||||||
{ type: 'or' }
|
{ type: 'or' }
|
||||||
);
|
);
|
||||||
|
|
||||||
const blocklistVisibility = hasPermission(
|
const blacklistVisibility = hasPermission(
|
||||||
[Permission.MANAGE_BLOCKLIST, Permission.VIEW_BLOCKLIST],
|
[Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST],
|
||||||
{ type: 'or' }
|
{ type: 'or' }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -360,9 +360,9 @@ const CollectionRequestModal = ({
|
|||||||
<tbody className="divide-y divide-gray-700">
|
<tbody className="divide-y divide-gray-700">
|
||||||
{data?.parts
|
{data?.parts
|
||||||
.filter((part) => {
|
.filter((part) => {
|
||||||
if (!blocklistVisibility)
|
if (!blacklistVisibility)
|
||||||
return (
|
return (
|
||||||
part.mediaInfo?.status !== MediaStatus.BLOCKLISTED
|
part.mediaInfo?.status !== MediaStatus.BLACKLISTED
|
||||||
);
|
);
|
||||||
return part;
|
return part;
|
||||||
})
|
})
|
||||||
@@ -381,7 +381,7 @@ const CollectionRequestModal = ({
|
|||||||
<tr key={`part-${part.id}`}>
|
<tr key={`part-${part.id}`}>
|
||||||
<td
|
<td
|
||||||
className={`whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100 ${
|
className={`whitespace-nowrap px-4 py-4 text-sm font-medium leading-5 text-gray-100 ${
|
||||||
partMedia?.status === MediaStatus.BLOCKLISTED &&
|
partMedia?.status === MediaStatus.BLACKLISTED &&
|
||||||
'pointer-events-none opacity-50'
|
'pointer-events-none opacity-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -391,7 +391,7 @@ const CollectionRequestModal = ({
|
|||||||
aria-checked={
|
aria-checked={
|
||||||
(!!partMedia &&
|
(!!partMedia &&
|
||||||
partMedia.status !==
|
partMedia.status !==
|
||||||
MediaStatus.BLOCKLISTED) ||
|
MediaStatus.BLACKLISTED) ||
|
||||||
isSelectedPart(part.id)
|
isSelectedPart(part.id)
|
||||||
}
|
}
|
||||||
onClick={() => togglePart(part.id)}
|
onClick={() => togglePart(part.id)}
|
||||||
@@ -403,7 +403,7 @@ const CollectionRequestModal = ({
|
|||||||
className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none ${
|
className={`relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center pt-2 focus:outline-none ${
|
||||||
(!!partMedia &&
|
(!!partMedia &&
|
||||||
partMedia.status !==
|
partMedia.status !==
|
||||||
MediaStatus.BLOCKLISTED) ||
|
MediaStatus.BLACKLISTED) ||
|
||||||
partRequest ||
|
partRequest ||
|
||||||
(quota?.movie.limit &&
|
(quota?.movie.limit &&
|
||||||
currentlyRemaining <= 0 &&
|
currentlyRemaining <= 0 &&
|
||||||
@@ -417,7 +417,7 @@ const CollectionRequestModal = ({
|
|||||||
className={`${
|
className={`${
|
||||||
(!!partMedia &&
|
(!!partMedia &&
|
||||||
partMedia.status !==
|
partMedia.status !==
|
||||||
MediaStatus.BLOCKLISTED) ||
|
MediaStatus.BLACKLISTED) ||
|
||||||
partRequest ||
|
partRequest ||
|
||||||
isSelectedPart(part.id)
|
isSelectedPart(part.id)
|
||||||
? 'bg-indigo-500'
|
? 'bg-indigo-500'
|
||||||
@@ -429,7 +429,7 @@ const CollectionRequestModal = ({
|
|||||||
className={`${
|
className={`${
|
||||||
(!!partMedia &&
|
(!!partMedia &&
|
||||||
partMedia.status !==
|
partMedia.status !==
|
||||||
MediaStatus.BLOCKLISTED) ||
|
MediaStatus.BLACKLISTED) ||
|
||||||
partRequest ||
|
partRequest ||
|
||||||
isSelectedPart(part.id)
|
isSelectedPart(part.id)
|
||||||
? 'translate-x-5'
|
? 'translate-x-5'
|
||||||
@@ -440,7 +440,7 @@ const CollectionRequestModal = ({
|
|||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className={`flex items-center px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6 ${
|
className={`flex items-center px-1 py-4 text-sm font-medium leading-5 text-gray-100 md:px-6 ${
|
||||||
partMedia?.status === MediaStatus.BLOCKLISTED &&
|
partMedia?.status === MediaStatus.BLACKLISTED &&
|
||||||
'pointer-events-none opacity-50'
|
'pointer-events-none opacity-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -502,9 +502,9 @@ const CollectionRequestModal = ({
|
|||||||
{intl.formatMessage(globalMessages.available)}
|
{intl.formatMessage(globalMessages.available)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{partMedia?.status === MediaStatus.BLOCKLISTED && (
|
{partMedia?.status === MediaStatus.BLACKLISTED && (
|
||||||
<Badge badgeType="danger">
|
<Badge badgeType="danger">
|
||||||
{intl.formatMessage(globalMessages.blocklisted)}
|
{intl.formatMessage(globalMessages.blacklisted)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const Search = () => {
|
|||||||
{
|
{
|
||||||
query: router.query.query,
|
query: router.query.query,
|
||||||
},
|
},
|
||||||
{ hideAvailable: false, hideBlocklisted: false }
|
{ hideAvailable: false, hideBlacklisted: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||