Compare commits
21 Commits
preview-re
...
preview-ov
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c8fb4f4bb | ||
|
|
98ecdf16d2 | ||
|
|
a8bd51f8ab | ||
|
|
6ee96604ec | ||
|
|
aa3670322d | ||
|
|
38eaa30cee | ||
|
|
c0fce76db7 | ||
|
|
92341c8d06 | ||
|
|
cbcb71436d | ||
|
|
39a5ccb7f3 | ||
|
|
9b151feb4f | ||
|
|
fe5d016929 | ||
|
|
14f316a9a6 | ||
|
|
5c24e79b1d | ||
|
|
ba84212e68 | ||
|
|
f25b32aec8 | ||
|
|
5a13226877 | ||
|
|
694913c767 | ||
|
|
a2d2fd3c2a | ||
|
|
cb94ad5a2e | ||
|
|
2829c2548a |
@@ -48,7 +48,7 @@ All help is welcome and greatly appreciated! If you would like to contribute to
|
|||||||
4. Run the development environment:
|
4. Run the development environment:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm
|
pnpm install
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"types": 0,
|
"types": 0,
|
||||||
"options": {
|
"options": {
|
||||||
"webhookUrl": "",
|
"webhookUrl": "",
|
||||||
|
"webhookRoleId": "",
|
||||||
"enableMentions": true
|
"enableMentions": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ location ^~ /jellyseerr {
|
|||||||
sub_filter '/api/v1' '/$app/api/v1';
|
sub_filter '/api/v1' '/$app/api/v1';
|
||||||
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
|
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
|
||||||
sub_filter '/images/' '/$app/images/';
|
sub_filter '/images/' '/$app/images/';
|
||||||
|
sub_filter '/imageproxy/' '/$app/imageproxy/';
|
||||||
|
sub_filter '/avatarproxy/' '/$app/avatarproxy/';
|
||||||
sub_filter '/android-' '/$app/android-';
|
sub_filter '/android-' '/$app/android-';
|
||||||
sub_filter '/apple-' '/$app/apple-';
|
sub_filter '/apple-' '/$app/apple-';
|
||||||
sub_filter '/favicon' '/$app/favicon';
|
sub_filter '/favicon' '/$app/favicon';
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ Users can optionally opt-in to being mentioned in Discord notifications by confi
|
|||||||
|
|
||||||
You can find the webhook URL in the Discord application, at **Server Settings → Integrations → Webhooks**.
|
You can find the webhook URL in the Discord application, at **Server Settings → Integrations → Webhooks**.
|
||||||
|
|
||||||
|
### Notification Role ID (optional)
|
||||||
|
|
||||||
|
If a role ID is specified, it will be included in the webhook message. See [Discord role ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID).
|
||||||
|
|
||||||
### Bot Username (optional)
|
### Bot Username (optional)
|
||||||
|
|
||||||
If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like!
|
If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like!
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ module.exports = {
|
|||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{ hostname: 'gravatar.com' },
|
{ hostname: 'gravatar.com' },
|
||||||
{ hostname: 'image.tmdb.org' },
|
{ hostname: 'image.tmdb.org' },
|
||||||
|
{ hostname: 'artworks.thetvdb.com' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
|
|||||||
@@ -1273,6 +1273,8 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
webhookUrl:
|
webhookUrl:
|
||||||
type: string
|
type: string
|
||||||
|
webhookRoleId:
|
||||||
|
type: string
|
||||||
enableMentions:
|
enableMentions:
|
||||||
type: boolean
|
type: boolean
|
||||||
SlackSettings:
|
SlackSettings:
|
||||||
@@ -1930,6 +1932,11 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
native_name:
|
native_name:
|
||||||
type: string
|
type: string
|
||||||
|
OverrideRule:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
cookieAuth:
|
cookieAuth:
|
||||||
type: apiKey
|
type: apiKey
|
||||||
@@ -3753,6 +3760,11 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
enum: [created, updated, requests, displayname]
|
enum: [created, updated, requests, displayname]
|
||||||
default: created
|
default: created
|
||||||
|
- in: query
|
||||||
|
name: q
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: A JSON array of all users
|
description: A JSON array of all users
|
||||||
@@ -4142,6 +4154,21 @@ paths:
|
|||||||
'412':
|
'412':
|
||||||
description: Item has already been blacklisted
|
description: Item has already been blacklisted
|
||||||
/blacklist/{tmdbId}:
|
/blacklist/{tmdbId}:
|
||||||
|
get:
|
||||||
|
summary: Get media from blacklist
|
||||||
|
tags:
|
||||||
|
- blacklist
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: tmdbId
|
||||||
|
description: tmdbId ID
|
||||||
|
required: true
|
||||||
|
example: '1'
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Blacklist details in JSON
|
||||||
delete:
|
delete:
|
||||||
summary: Remove media from blacklist
|
summary: Remove media from blacklist
|
||||||
tags:
|
tags:
|
||||||
@@ -6939,6 +6966,68 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/WatchProviderDetails'
|
$ref: '#/components/schemas/WatchProviderDetails'
|
||||||
|
/overrideRule:
|
||||||
|
get:
|
||||||
|
summary: Get override rules
|
||||||
|
description: Returns a list of all override rules with their conditions and settings
|
||||||
|
tags:
|
||||||
|
- overriderule
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Override rules returned
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/OverrideRule'
|
||||||
|
post:
|
||||||
|
summary: Create override rule
|
||||||
|
description: Creates a new Override Rule from the request body.
|
||||||
|
tags:
|
||||||
|
- overriderule
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 'Values were successfully created'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/OverrideRule'
|
||||||
|
/overrideRule/{ruleId}:
|
||||||
|
put:
|
||||||
|
summary: Update override rule
|
||||||
|
description: Updates an Override Rule from the request body.
|
||||||
|
tags:
|
||||||
|
- overriderule
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 'Values were successfully updated'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/OverrideRule'
|
||||||
|
delete:
|
||||||
|
summary: Delete override rule by ID
|
||||||
|
description: Deletes the override rule with the provided ruleId.
|
||||||
|
tags:
|
||||||
|
- overriderule
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: ruleId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Override rule successfully deleted
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OverrideRule'
|
||||||
security:
|
security:
|
||||||
- cookieAuth: []
|
- cookieAuth: []
|
||||||
- apiKey: []
|
- apiKey: []
|
||||||
|
|||||||
@@ -138,39 +138,38 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
try {
|
try {
|
||||||
return await authenticate(true);
|
return await authenticate(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.debug(`Failed to authenticate with headers: ${e.message}`, {
|
logger.debug('Failed to authenticate with headers', {
|
||||||
label: 'Jellyfin API',
|
label: 'Jellyfin API',
|
||||||
|
error: e.cause.message ?? e.cause.statusText,
|
||||||
ip: ClientIP,
|
ip: ClientIP,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!e.cause.status) {
|
||||||
|
throw new ApiError(404, ApiErrorCode.InvalidUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.cause.status === 401) {
|
||||||
|
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await authenticate(false);
|
return await authenticate(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const status = e.cause?.status;
|
if (e.cause.status === 401) {
|
||||||
|
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
|
||||||
const networkErrorCodes = new Set([
|
|
||||||
'ECONNREFUSED',
|
|
||||||
'EHOSTUNREACH',
|
|
||||||
'ENOTFOUND',
|
|
||||||
'ETIMEDOUT',
|
|
||||||
'ECONNRESET',
|
|
||||||
'EADDRINUSE',
|
|
||||||
'ENETDOWN',
|
|
||||||
'ENETUNREACH',
|
|
||||||
'EPIPE',
|
|
||||||
'ECONNABORTED',
|
|
||||||
'EPROTO',
|
|
||||||
'EHOSTDOWN',
|
|
||||||
'EAI_AGAIN',
|
|
||||||
'ERR_INVALID_URL',
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (networkErrorCodes.has(e.code) || status === 404) {
|
|
||||||
throw new ApiError(status, ApiErrorCode.InvalidUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new ApiError(status, ApiErrorCode.InvalidCredentials);
|
logger.error(
|
||||||
|
'Something went wrong while authenticating with the Jellyfin server',
|
||||||
|
{
|
||||||
|
label: 'Jellyfin API',
|
||||||
|
error: e.cause.message ?? e.cause.statusText,
|
||||||
|
ip: ClientIP,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new ApiError(e.cause.status, ApiErrorCode.Unknown);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,8 +197,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return serverResponse.ServerName;
|
return serverResponse.ServerName;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
|
'Something went wrong while getting the server name from the Jellyfin server',
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
|
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
|
||||||
@@ -213,8 +212,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return { users: userReponse };
|
return { users: userReponse };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
'Something went wrong while getting the account from the Jellyfin server',
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
@@ -229,8 +228,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return userReponse;
|
return userReponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
|
'Something went wrong while getting the account from the Jellyfin server',
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
@@ -253,8 +252,11 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return this.mapLibraries(mediaFolderResponse.Items);
|
return this.mapLibraries(mediaFolderResponse.Items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
|
'Something went wrong while getting libraries from the Jellyfin server',
|
||||||
{ label: 'Jellyfin API' }
|
{
|
||||||
|
label: 'Jellyfin API',
|
||||||
|
error: e.cause.message ?? e.cause.statusText,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
@@ -308,8 +310,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
'Something went wrong while getting library content from the Jellyfin server',
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
@@ -329,8 +331,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return itemResponse;
|
return itemResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
'Something went wrong while getting library content from the Jellyfin server',
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
@@ -354,8 +356,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
|
'Something went wrong while getting library content from the Jellyfin server',
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||||
);
|
);
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
}
|
}
|
||||||
@@ -368,8 +370,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
return seasonResponse.Items;
|
return seasonResponse.Items;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
|
'Something went wrong while getting the list of seasons from the Jellyfin server',
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
@@ -393,8 +395,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
|
'Something went wrong while getting the list of episodes from the Jellyfin server',
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
@@ -410,8 +412,8 @@ class JellyfinAPI extends ExternalAPI {
|
|||||||
).AccessToken;
|
).AccessToken;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Something went wrong while creating an API key from the Jellyfin server: ${e.message}`,
|
'Something went wrong while creating an API key from the Jellyfin server',
|
||||||
{ label: 'Jellyfin API' }
|
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
|
||||||
);
|
);
|
||||||
|
|
||||||
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);
|
||||||
|
|||||||
@@ -80,12 +80,12 @@ export class Blacklist implements BlacklistItem {
|
|||||||
status: MediaStatus.BLACKLISTED,
|
status: MediaStatus.BLACKLISTED,
|
||||||
status4k: MediaStatus.BLACKLISTED,
|
status4k: MediaStatus.BLACKLISTED,
|
||||||
mediaType: blacklistRequest.mediaType,
|
mediaType: blacklistRequest.mediaType,
|
||||||
blacklist: blacklist,
|
blacklist: Promise.resolve(blacklist),
|
||||||
});
|
});
|
||||||
|
|
||||||
await mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
} else {
|
} else {
|
||||||
media.blacklist = blacklist;
|
media.blacklist = Promise.resolve(blacklist);
|
||||||
media.status = MediaStatus.BLACKLISTED;
|
media.status = MediaStatus.BLACKLISTED;
|
||||||
media.status4k = MediaStatus.BLACKLISTED;
|
media.status4k = MediaStatus.BLACKLISTED;
|
||||||
|
|
||||||
|
|||||||
@@ -118,10 +118,8 @@ class Media {
|
|||||||
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
|
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
|
||||||
public issues: Issue[];
|
public issues: Issue[];
|
||||||
|
|
||||||
@OneToOne(() => Blacklist, (blacklist) => blacklist.media, {
|
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
|
||||||
eager: true,
|
public blacklist: Promise<Blacklist>;
|
||||||
})
|
|
||||||
public blacklist: Blacklist;
|
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
public createdAt: Date;
|
public createdAt: Date;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
MediaType,
|
MediaType,
|
||||||
} from '@server/constants/media';
|
} from '@server/constants/media';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
|
import OverrideRule from '@server/entity/OverrideRule';
|
||||||
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
|
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
|
||||||
import notificationManager, { Notification } from '@server/lib/notifications';
|
import notificationManager, { Notification } from '@server/lib/notifications';
|
||||||
import { Permission } from '@server/lib/permissions';
|
import { Permission } from '@server/lib/permissions';
|
||||||
@@ -713,48 +714,6 @@ export class MediaRequest {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let rootFolder = radarrSettings.activeDirectory;
|
|
||||||
let qualityProfile = radarrSettings.activeProfileId;
|
|
||||||
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.rootFolder &&
|
|
||||||
this.rootFolder !== '' &&
|
|
||||||
this.rootFolder !== radarrSettings.activeDirectory
|
|
||||||
) {
|
|
||||||
rootFolder = this.rootFolder;
|
|
||||||
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
this.profileId &&
|
|
||||||
this.profileId !== radarrSettings.activeProfileId
|
|
||||||
) {
|
|
||||||
qualityProfile = this.profileId;
|
|
||||||
logger.info(
|
|
||||||
`Request has an override quality profile ID: ${qualityProfile}`,
|
|
||||||
{
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.tags && !isEqual(this.tags, radarrSettings.tags)) {
|
|
||||||
tags = this.tags;
|
|
||||||
logger.info(`Request has override tags`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
tagIds: tags,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
const radarr = new RadarrAPI({
|
const radarr = new RadarrAPI({
|
||||||
apiKey: radarrSettings.apiKey,
|
apiKey: radarrSettings.apiKey,
|
||||||
@@ -775,6 +734,151 @@ export class MediaRequest {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let rootFolder = radarrSettings.activeDirectory;
|
||||||
|
let qualityProfile = radarrSettings.activeProfileId;
|
||||||
|
let tags = radarrSettings.tags ? [...radarrSettings.tags] : [];
|
||||||
|
|
||||||
|
const overrideRuleRepository = getRepository(OverrideRule);
|
||||||
|
const overrideRules = await overrideRuleRepository.find({
|
||||||
|
where: { radarrServiceId: radarrSettings.id },
|
||||||
|
});
|
||||||
|
const appliedOverrideRules = overrideRules.filter((rule) => {
|
||||||
|
if (
|
||||||
|
rule.users &&
|
||||||
|
!rule.users
|
||||||
|
.split(',')
|
||||||
|
.some((userId) => Number(userId) === this.requestedBy.id)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rule.genre &&
|
||||||
|
!rule.genre
|
||||||
|
.split(',')
|
||||||
|
.some((genreId) =>
|
||||||
|
movie.genres.map((genre) => genre.id).includes(Number(genreId))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rule.language &&
|
||||||
|
!rule.language
|
||||||
|
.split('|')
|
||||||
|
.some((languageId) => languageId === movie.original_language)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rule.keywords &&
|
||||||
|
!rule.keywords
|
||||||
|
.split(',')
|
||||||
|
.some((keywordId) =>
|
||||||
|
movie.keywords.keywords
|
||||||
|
.map((keyword) => keyword.id)
|
||||||
|
.includes(Number(keywordId))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.rootFolder &&
|
||||||
|
this.rootFolder !== '' &&
|
||||||
|
this.rootFolder !== radarrSettings.activeDirectory
|
||||||
|
) {
|
||||||
|
rootFolder = this.rootFolder;
|
||||||
|
logger.info(
|
||||||
|
`Request has a manually overriden root folder: ${rootFolder}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const overrideRootFolder = appliedOverrideRules.find(
|
||||||
|
(rule) => rule.rootFolder
|
||||||
|
)?.rootFolder;
|
||||||
|
if (overrideRootFolder) {
|
||||||
|
rootFolder = overrideRootFolder;
|
||||||
|
this.rootFolder = rootFolder;
|
||||||
|
logger.info(
|
||||||
|
`Request has an override root folder from override rules: ${rootFolder}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.profileId &&
|
||||||
|
this.profileId !== radarrSettings.activeProfileId
|
||||||
|
) {
|
||||||
|
qualityProfile = this.profileId;
|
||||||
|
logger.info(
|
||||||
|
`Request has a manually overriden quality profile ID: ${qualityProfile}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const overrideProfileId = appliedOverrideRules.find(
|
||||||
|
(rule) => rule.profileId
|
||||||
|
)?.profileId;
|
||||||
|
if (overrideProfileId) {
|
||||||
|
qualityProfile = overrideProfileId;
|
||||||
|
this.profileId = qualityProfile;
|
||||||
|
logger.info(
|
||||||
|
`Request has an override quality profile ID from override rules: ${qualityProfile}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.tags && !isEqual(this.tags, radarrSettings.tags)) {
|
||||||
|
tags = this.tags;
|
||||||
|
logger.info(`Request has manually overriden tags`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
tagIds: tags,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const overrideTags = appliedOverrideRules.find(
|
||||||
|
(rule) => rule.tags
|
||||||
|
)?.tags;
|
||||||
|
if (overrideTags) {
|
||||||
|
tags = [
|
||||||
|
...new Set([
|
||||||
|
...tags,
|
||||||
|
...overrideTags.split(',').map((tag) => Number(tag)),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
this.tags = tags;
|
||||||
|
logger.info(`Request has override tags from override rules`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
tagIds: tags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
requestRepository.save(this);
|
||||||
|
|
||||||
if (radarrSettings.tagRequests) {
|
if (radarrSettings.tagRequests) {
|
||||||
let userTag = (await radarr.getTags()).find((v) =>
|
let userTag = (await radarr.getTags()).find((v) =>
|
||||||
v.label.startsWith(this.requestedBy.id + ' - ')
|
v.label.startsWith(this.requestedBy.id + ' - ')
|
||||||
@@ -816,7 +920,6 @@ export class MediaRequest {
|
|||||||
mediaId: this.media.id,
|
mediaId: this.media.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
this.status = MediaRequestStatus.APPROVED;
|
this.status = MediaRequestStatus.APPROVED;
|
||||||
await requestRepository.save(this);
|
await requestRepository.save(this);
|
||||||
return;
|
return;
|
||||||
@@ -856,8 +959,6 @@ export class MediaRequest {
|
|||||||
await mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
})
|
})
|
||||||
.catch(async () => {
|
.catch(async () => {
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
|
|
||||||
this.status = MediaRequestStatus.FAILED;
|
this.status = MediaRequestStatus.FAILED;
|
||||||
requestRepository.save(this);
|
requestRepository.save(this);
|
||||||
|
|
||||||
@@ -957,6 +1058,7 @@ export class MediaRequest {
|
|||||||
throw new Error('Media data not found');
|
throw new Error('Media data not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
if (
|
if (
|
||||||
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
|
||||||
) {
|
) {
|
||||||
@@ -966,7 +1068,6 @@ export class MediaRequest {
|
|||||||
mediaId: this.media.id,
|
mediaId: this.media.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
this.status = MediaRequestStatus.APPROVED;
|
this.status = MediaRequestStatus.APPROVED;
|
||||||
await requestRepository.save(this);
|
await requestRepository.save(this);
|
||||||
return;
|
return;
|
||||||
@@ -981,7 +1082,6 @@ export class MediaRequest {
|
|||||||
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
|
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
|
||||||
|
|
||||||
if (!tvdbId) {
|
if (!tvdbId) {
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
await mediaRepository.remove(media);
|
await mediaRepository.remove(media);
|
||||||
await requestRepository.remove(this);
|
await requestRepository.remove(this);
|
||||||
throw new Error('TVDB ID not found');
|
throw new Error('TVDB ID not found');
|
||||||
@@ -1019,29 +1119,110 @@ export class MediaRequest {
|
|||||||
? [...sonarrSettings.tags]
|
? [...sonarrSettings.tags]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const overrideRuleRepository = getRepository(OverrideRule);
|
||||||
|
const overrideRules = await overrideRuleRepository.find({
|
||||||
|
where: { sonarrServiceId: sonarrSettings.id },
|
||||||
|
});
|
||||||
|
const appliedOverrideRules = overrideRules.filter((rule) => {
|
||||||
|
if (
|
||||||
|
rule.users &&
|
||||||
|
!rule.users
|
||||||
|
.split(',')
|
||||||
|
.some((userId) => Number(userId) === this.requestedBy.id)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rule.genre &&
|
||||||
|
!rule.genre
|
||||||
|
.split(',')
|
||||||
|
.some((genreId) =>
|
||||||
|
series.genres.map((genre) => genre.id).includes(Number(genreId))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rule.language &&
|
||||||
|
!rule.language
|
||||||
|
.split('|')
|
||||||
|
.some((languageId) => languageId === series.original_language)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rule.keywords &&
|
||||||
|
!rule.keywords
|
||||||
|
.split(',')
|
||||||
|
.some((keywordId) =>
|
||||||
|
series.keywords.results
|
||||||
|
.map((keyword) => keyword.id)
|
||||||
|
.includes(Number(keywordId))
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.rootFolder &&
|
this.rootFolder &&
|
||||||
this.rootFolder !== '' &&
|
this.rootFolder !== '' &&
|
||||||
this.rootFolder !== rootFolder
|
this.rootFolder !== rootFolder
|
||||||
) {
|
) {
|
||||||
rootFolder = this.rootFolder;
|
rootFolder = this.rootFolder;
|
||||||
logger.info(`Request has an override root folder: ${rootFolder}`, {
|
|
||||||
label: 'Media Request',
|
|
||||||
requestId: this.id,
|
|
||||||
mediaId: this.media.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.profileId && this.profileId !== qualityProfile) {
|
|
||||||
qualityProfile = this.profileId;
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`Request has an override quality profile ID: ${qualityProfile}`,
|
`Request has a manually overriden root folder: ${rootFolder}`,
|
||||||
{
|
{
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
requestId: this.id,
|
requestId: this.id,
|
||||||
mediaId: this.media.id,
|
mediaId: this.media.id,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
const overrideRootFolder = appliedOverrideRules.find(
|
||||||
|
(rule) => rule.rootFolder
|
||||||
|
)?.rootFolder;
|
||||||
|
if (overrideRootFolder) {
|
||||||
|
rootFolder = overrideRootFolder;
|
||||||
|
this.rootFolder = rootFolder;
|
||||||
|
logger.info(
|
||||||
|
`Request has an override root folder from override rules: ${rootFolder}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.profileId && this.profileId !== qualityProfile) {
|
||||||
|
qualityProfile = this.profileId;
|
||||||
|
logger.info(
|
||||||
|
`Request has a manually overriden quality profile ID: ${qualityProfile}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const overrideProfileId = appliedOverrideRules.find(
|
||||||
|
(rule) => rule.profileId
|
||||||
|
)?.profileId;
|
||||||
|
if (overrideProfileId) {
|
||||||
|
qualityProfile = overrideProfileId;
|
||||||
|
this.profileId = qualityProfile;
|
||||||
|
logger.info(
|
||||||
|
`Request has an override quality profile ID from override rules: ${qualityProfile}`,
|
||||||
|
{
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -1061,12 +1242,31 @@ export class MediaRequest {
|
|||||||
|
|
||||||
if (this.tags && !isEqual(this.tags, tags)) {
|
if (this.tags && !isEqual(this.tags, tags)) {
|
||||||
tags = this.tags;
|
tags = this.tags;
|
||||||
logger.info(`Request has override tags`, {
|
logger.info(`Request has manually overriden tags`, {
|
||||||
label: 'Media Request',
|
label: 'Media Request',
|
||||||
requestId: this.id,
|
requestId: this.id,
|
||||||
mediaId: this.media.id,
|
mediaId: this.media.id,
|
||||||
tagIds: tags,
|
tagIds: tags,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
const overrideTags = appliedOverrideRules.find(
|
||||||
|
(rule) => rule.tags
|
||||||
|
)?.tags;
|
||||||
|
if (overrideTags) {
|
||||||
|
tags = [
|
||||||
|
...new Set([
|
||||||
|
...tags,
|
||||||
|
...overrideTags.split(',').map((tag) => Number(tag)),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
this.tags = tags;
|
||||||
|
logger.info(`Request has override tags from override rules`, {
|
||||||
|
label: 'Media Request',
|
||||||
|
requestId: this.id,
|
||||||
|
mediaId: this.media.id,
|
||||||
|
tagIds: tags,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sonarrSettings.tagRequests) {
|
if (sonarrSettings.tagRequests) {
|
||||||
@@ -1101,6 +1301,8 @@ export class MediaRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requestRepository.save(this);
|
||||||
|
|
||||||
const sonarrSeriesOptions: AddSeriesOptions = {
|
const sonarrSeriesOptions: AddSeriesOptions = {
|
||||||
profileId: qualityProfile,
|
profileId: qualityProfile,
|
||||||
languageProfileId: languageProfile,
|
languageProfileId: languageProfile,
|
||||||
@@ -1137,8 +1339,6 @@ export class MediaRequest {
|
|||||||
await mediaRepository.save(media);
|
await mediaRepository.save(media);
|
||||||
})
|
})
|
||||||
.catch(async () => {
|
.catch(async () => {
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
|
|
||||||
this.status = MediaRequestStatus.FAILED;
|
this.status = MediaRequestStatus.FAILED;
|
||||||
requestRepository.save(this);
|
requestRepository.save(this);
|
||||||
|
|
||||||
|
|||||||
52
server/entity/OverrideRule.ts
Normal file
52
server/entity/OverrideRule.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
class OverrideRule {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
public id: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
public radarrServiceId?: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
public sonarrServiceId?: number;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public users?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public genre?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public language?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public keywords?: string;
|
||||||
|
|
||||||
|
@Column({ type: 'int', nullable: true })
|
||||||
|
public profileId?: number;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public rootFolder?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
public tags?: string;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
public createdAt: Date;
|
||||||
|
|
||||||
|
@UpdateDateColumn()
|
||||||
|
public updatedAt: Date;
|
||||||
|
|
||||||
|
constructor(init?: Partial<OverrideRule>) {
|
||||||
|
Object.assign(this, init);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OverrideRule;
|
||||||
3
server/interfaces/api/overrideRuleInterfaces.ts
Normal file
3
server/interfaces/api/overrideRuleInterfaces.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import type OverrideRule from '@server/entity/OverrideRule';
|
||||||
|
|
||||||
|
export type OverrideRuleResultsResponse = OverrideRule[];
|
||||||
@@ -291,6 +291,10 @@ class DiscordAgent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settings.options.webhookRoleId) {
|
||||||
|
userMentions.push(`<@&${settings.options.webhookRoleId}>`);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(settings.options.webhookUrl, {
|
const response = await fetch(settings.options.webhookUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export interface DVRSettings {
|
|||||||
syncEnabled: boolean;
|
syncEnabled: boolean;
|
||||||
preventSearch: boolean;
|
preventSearch: boolean;
|
||||||
tagRequests: boolean;
|
tagRequests: boolean;
|
||||||
|
overrideRule: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RadarrSettings extends DVRSettings {
|
export interface RadarrSettings extends DVRSettings {
|
||||||
@@ -170,6 +171,7 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig {
|
|||||||
botUsername?: string;
|
botUsername?: string;
|
||||||
botAvatarUrl?: string;
|
botAvatarUrl?: string;
|
||||||
webhookUrl: string;
|
webhookUrl: string;
|
||||||
|
webhookRoleId?: string;
|
||||||
enableMentions: boolean;
|
enableMentions: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -394,6 +396,7 @@ class Settings {
|
|||||||
types: 0,
|
types: 0,
|
||||||
options: {
|
options: {
|
||||||
webhookUrl: '',
|
webhookUrl: '',
|
||||||
|
webhookRoleId: '',
|
||||||
enableMentions: true,
|
enableMentions: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
15
server/migration/1731963944025-AddOverrideRules.ts
Normal file
15
server/migration/1731963944025-AddOverrideRules.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddOverrideRules1731963944025 implements MigrationInterface {
|
||||||
|
name = 'AddOverrideRules1731963944025';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "override_rule" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "radarrServiceId" integer, "sonarrServiceId" integer, "users" varchar, "genre" varchar, "language" varchar, "keywords" varchar, "profileId" integer, "rootFolder" varchar, "tags" varchar, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE "override_rule"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,12 @@ import { MediaType } from '@server/constants/media';
|
|||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import { Blacklist } from '@server/entity/Blacklist';
|
import { Blacklist } from '@server/entity/Blacklist';
|
||||||
import Media from '@server/entity/Media';
|
import Media from '@server/entity/Media';
|
||||||
import { NotFoundError } from '@server/entity/Watchlist';
|
|
||||||
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
|
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';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import rateLimit from 'express-rate-limit';
|
import { EntityNotFoundError, QueryFailedError } from 'typeorm';
|
||||||
import { QueryFailedError } from 'typeorm';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const blacklistRoutes = Router();
|
const blacklistRoutes = Router();
|
||||||
@@ -26,7 +24,6 @@ blacklistRoutes.get(
|
|||||||
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
|
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
|
||||||
type: 'or',
|
type: 'or',
|
||||||
}),
|
}),
|
||||||
rateLimit({ windowMs: 60 * 1000, max: 50 }),
|
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
const pageSize = req.query.take ? Number(req.query.take) : 25;
|
const pageSize = req.query.take ? Number(req.query.take) : 25;
|
||||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||||
@@ -71,6 +68,32 @@ blacklistRoutes.get(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
blacklistRoutes.get(
|
||||||
|
'/:id',
|
||||||
|
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
||||||
|
type: 'or',
|
||||||
|
}),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const blacklisteRepository = getRepository(Blacklist);
|
||||||
|
|
||||||
|
const blacklistItem = await blacklisteRepository.findOneOrFail({
|
||||||
|
where: { tmdbId: Number(req.params.id) },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(200).send(blacklistItem);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof EntityNotFoundError) {
|
||||||
|
return next({
|
||||||
|
status: 401,
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return next({ status: 500, message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
blacklistRoutes.post(
|
blacklistRoutes.post(
|
||||||
'/',
|
'/',
|
||||||
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
isAuthenticated([Permission.MANAGE_BLACKLIST], {
|
||||||
@@ -134,7 +157,7 @@ blacklistRoutes.delete(
|
|||||||
|
|
||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof NotFoundError) {
|
if (e instanceof EntityNotFoundError) {
|
||||||
return next({
|
return next({
|
||||||
status: 401,
|
status: 401,
|
||||||
message: e.message,
|
message: e.message,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { checkUser, isAuthenticated } from '@server/middleware/auth';
|
|||||||
import { mapWatchProviderDetails } from '@server/models/common';
|
import { mapWatchProviderDetails } from '@server/models/common';
|
||||||
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 overrideRuleRoutes from '@server/routes/overrideRule';
|
||||||
import settingsRoutes from '@server/routes/settings';
|
import settingsRoutes from '@server/routes/settings';
|
||||||
import watchlistRoutes from '@server/routes/watchlist';
|
import watchlistRoutes from '@server/routes/watchlist';
|
||||||
import {
|
import {
|
||||||
@@ -160,6 +161,11 @@ router.use('/service', isAuthenticated(), serviceRoutes);
|
|||||||
router.use('/issue', isAuthenticated(), issueRoutes);
|
router.use('/issue', isAuthenticated(), issueRoutes);
|
||||||
router.use('/issueComment', isAuthenticated(), issueCommentRoutes);
|
router.use('/issueComment', isAuthenticated(), issueCommentRoutes);
|
||||||
router.use('/auth', authRoutes);
|
router.use('/auth', authRoutes);
|
||||||
|
router.use(
|
||||||
|
'/overrideRule',
|
||||||
|
isAuthenticated(Permission.ADMIN),
|
||||||
|
overrideRuleRoutes
|
||||||
|
);
|
||||||
|
|
||||||
router.get('/regions', isAuthenticated(), async (req, res, next) => {
|
router.get('/regions', isAuthenticated(), async (req, res, next) => {
|
||||||
const tmdb = new TheMovieDb();
|
const tmdb = new TheMovieDb();
|
||||||
|
|||||||
136
server/routes/overrideRule.ts
Normal file
136
server/routes/overrideRule.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { getRepository } from '@server/datasource';
|
||||||
|
import OverrideRule from '@server/entity/OverrideRule';
|
||||||
|
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
|
||||||
|
import { Permission } from '@server/lib/permissions';
|
||||||
|
import { isAuthenticated } from '@server/middleware/auth';
|
||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
const overrideRuleRoutes = Router();
|
||||||
|
|
||||||
|
overrideRuleRoutes.get(
|
||||||
|
'/',
|
||||||
|
isAuthenticated(Permission.ADMIN),
|
||||||
|
async (req, res, next) => {
|
||||||
|
const overrideRuleRepository = getRepository(OverrideRule);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rules = await overrideRuleRepository.find({});
|
||||||
|
|
||||||
|
return res.status(200).json(rules as OverrideRuleResultsResponse);
|
||||||
|
} catch (e) {
|
||||||
|
next({ status: 404, message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
overrideRuleRoutes.post<
|
||||||
|
Record<string, string>,
|
||||||
|
OverrideRule,
|
||||||
|
{
|
||||||
|
users?: string;
|
||||||
|
genre?: string;
|
||||||
|
language?: string;
|
||||||
|
keywords?: string;
|
||||||
|
profileId?: number;
|
||||||
|
rootFolder?: string;
|
||||||
|
tags?: string;
|
||||||
|
radarrServiceId?: number;
|
||||||
|
sonarrServiceId?: number;
|
||||||
|
}
|
||||||
|
>('/', isAuthenticated(Permission.ADMIN), async (req, res, next) => {
|
||||||
|
const overrideRuleRepository = getRepository(OverrideRule);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rule = new OverrideRule({
|
||||||
|
users: req.body.users,
|
||||||
|
genre: req.body.genre,
|
||||||
|
language: req.body.language,
|
||||||
|
keywords: req.body.keywords,
|
||||||
|
profileId: req.body.profileId,
|
||||||
|
rootFolder: req.body.rootFolder,
|
||||||
|
tags: req.body.tags,
|
||||||
|
radarrServiceId: req.body.radarrServiceId,
|
||||||
|
sonarrServiceId: req.body.sonarrServiceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newRule = await overrideRuleRepository.save(rule);
|
||||||
|
|
||||||
|
return res.status(200).json(newRule);
|
||||||
|
} catch (e) {
|
||||||
|
next({ status: 404, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
overrideRuleRoutes.put<
|
||||||
|
{ ruleId: string },
|
||||||
|
OverrideRule,
|
||||||
|
{
|
||||||
|
users?: string;
|
||||||
|
genre?: string;
|
||||||
|
language?: string;
|
||||||
|
keywords?: string;
|
||||||
|
profileId?: number;
|
||||||
|
rootFolder?: string;
|
||||||
|
tags?: string;
|
||||||
|
radarrServiceId?: number;
|
||||||
|
sonarrServiceId?: number;
|
||||||
|
}
|
||||||
|
>('/:ruleId', isAuthenticated(Permission.ADMIN), async (req, res, next) => {
|
||||||
|
const overrideRuleRepository = getRepository(OverrideRule);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rule = await overrideRuleRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: Number(req.params.ruleId),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rule) {
|
||||||
|
return next({ status: 404, message: 'Override Rule not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
rule.users = req.body.users;
|
||||||
|
rule.genre = req.body.genre;
|
||||||
|
rule.language = req.body.language;
|
||||||
|
rule.keywords = req.body.keywords;
|
||||||
|
rule.profileId = req.body.profileId;
|
||||||
|
rule.rootFolder = req.body.rootFolder;
|
||||||
|
rule.tags = req.body.tags;
|
||||||
|
rule.radarrServiceId = req.body.radarrServiceId;
|
||||||
|
rule.sonarrServiceId = req.body.sonarrServiceId;
|
||||||
|
|
||||||
|
const newRule = await overrideRuleRepository.save(rule);
|
||||||
|
|
||||||
|
return res.status(200).json(newRule);
|
||||||
|
} catch (e) {
|
||||||
|
next({ status: 404, message: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
overrideRuleRoutes.delete<{ ruleId: string }, OverrideRule>(
|
||||||
|
'/:ruleId',
|
||||||
|
isAuthenticated(Permission.ADMIN),
|
||||||
|
async (req, res, next) => {
|
||||||
|
const overrideRuleRepository = getRepository(OverrideRule);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rule = await overrideRuleRepository.findOne({
|
||||||
|
where: {
|
||||||
|
id: Number(req.params.ruleId),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rule) {
|
||||||
|
return next({ status: 404, message: 'Override Rule not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await overrideRuleRepository.remove(rule);
|
||||||
|
|
||||||
|
return res.status(200).json(rule);
|
||||||
|
} catch (e) {
|
||||||
|
next({ status: 404, message: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default overrideRuleRoutes;
|
||||||
@@ -34,8 +34,16 @@ router.get('/', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const pageSize = req.query.take ? Number(req.query.take) : 10;
|
const pageSize = req.query.take ? Number(req.query.take) : 10;
|
||||||
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
const skip = req.query.skip ? Number(req.query.skip) : 0;
|
||||||
|
const q = req.query.q ? req.query.q.toString().toLowerCase() : '';
|
||||||
let query = getRepository(User).createQueryBuilder('user');
|
let query = getRepository(User).createQueryBuilder('user');
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
query = query.where(
|
||||||
|
'LOWER(user.username) LIKE :q OR LOWER(user.email) LIKE :q OR LOWER(user.plexUsername) LIKE :q OR LOWER(user.jellyfinUsername) LIKE :q',
|
||||||
|
{ q: `%${q}%` }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
switch (req.query.sort) {
|
switch (req.query.sort) {
|
||||||
case 'updated':
|
case 'updated':
|
||||||
query = query.orderBy('user.updatedAt', 'DESC');
|
query = query.orderBy('user.updatedAt', 'DESC');
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ApiErrorCode } from '@server/constants/error';
|
import { ApiErrorCode } from '@server/constants/error';
|
||||||
|
import { UserType } from '@server/constants/user';
|
||||||
import { getRepository } from '@server/datasource';
|
import { getRepository } from '@server/datasource';
|
||||||
import { User } from '@server/entity/User';
|
import { User } from '@server/entity/User';
|
||||||
import { UserSettings } from '@server/entity/UserSettings';
|
import { UserSettings } from '@server/entity/UserSettings';
|
||||||
@@ -99,11 +100,29 @@ userSettingsRoutes.post<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
user.username = req.body.username;
|
|
||||||
const oldEmail = user.email;
|
const oldEmail = user.email;
|
||||||
|
const oldUsername = user.username;
|
||||||
|
user.username = req.body.username;
|
||||||
if (user.jellyfinUsername) {
|
if (user.jellyfinUsername) {
|
||||||
user.email = req.body.email || user.jellyfinUsername || user.email;
|
user.email = req.body.email || user.jellyfinUsername || user.email;
|
||||||
}
|
}
|
||||||
|
// Edge case for local users, because they have no Jellyfin username to fall back on
|
||||||
|
// if the email is not provided
|
||||||
|
if (user.userType === UserType.LOCAL) {
|
||||||
|
if (req.body.email) {
|
||||||
|
user.email = req.body.email;
|
||||||
|
if (
|
||||||
|
!user.username &&
|
||||||
|
user.email !== oldEmail &&
|
||||||
|
!oldEmail.includes('@')
|
||||||
|
) {
|
||||||
|
user.username = oldEmail;
|
||||||
|
}
|
||||||
|
} else if (req.body.username) {
|
||||||
|
user.email = oldUsername || user.email;
|
||||||
|
user.username = req.body.username;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const existingUser = await userRepository.findOne({
|
const existingUser = await userRepository.findOne({
|
||||||
where: { email: user.email },
|
where: { email: user.email },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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 Tooltip from '@app/components/Common/Tooltip';
|
import Tooltip from '@app/components/Common/Tooltip';
|
||||||
import { useUser } from '@app/hooks/useUser';
|
import { useUser } from '@app/hooks/useUser';
|
||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
@@ -10,6 +11,7 @@ import Link from 'next/link';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
const messages = defineMessages('component.BlacklistBlock', {
|
const messages = defineMessages('component.BlacklistBlock', {
|
||||||
blacklistedby: 'Blacklisted By',
|
blacklistedby: 'Blacklisted By',
|
||||||
@@ -17,13 +19,13 @@ const messages = defineMessages('component.BlacklistBlock', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
interface BlacklistBlockProps {
|
interface BlacklistBlockProps {
|
||||||
blacklistItem: Blacklist;
|
tmdbId: number;
|
||||||
onUpdate?: () => void;
|
onUpdate?: () => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BlacklistBlock = ({
|
const BlacklistBlock = ({
|
||||||
blacklistItem,
|
tmdbId,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: BlacklistBlockProps) => {
|
}: BlacklistBlockProps) => {
|
||||||
@@ -31,6 +33,7 @@ const BlacklistBlock = ({
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
|
const { data } = useSWR<Blacklist>(`/api/v1/blacklist/${tmdbId}`);
|
||||||
|
|
||||||
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
|
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
@@ -62,6 +65,14 @@ const BlacklistBlock = ({
|
|||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LoadingSpinner />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-3 text-gray-300">
|
<div className="px-4 py-3 text-gray-300">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -73,13 +84,13 @@ const BlacklistBlock = ({
|
|||||||
<span className="w-40 truncate md:w-auto">
|
<span className="w-40 truncate md:w-auto">
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
blacklistItem.user.id === user?.id
|
data.user.id === user?.id
|
||||||
? '/profile'
|
? '/profile'
|
||||||
: `/users/${blacklistItem.user.id}`
|
: `/users/${data.user.id}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
|
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
|
||||||
{blacklistItem.user.displayName}
|
{data.user.displayName}
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</span>
|
</span>
|
||||||
@@ -91,9 +102,7 @@ const BlacklistBlock = ({
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
buttonType="danger"
|
buttonType="danger"
|
||||||
onClick={() =>
|
onClick={() => removeFromBlacklist(data.tmdbId, data.title)}
|
||||||
removeFromBlacklist(blacklistItem.tmdbId, blacklistItem.title)
|
|
||||||
}
|
|
||||||
disabled={isUpdating}
|
disabled={isUpdating}
|
||||||
>
|
>
|
||||||
<TrashIcon className="icon-sm" />
|
<TrashIcon className="icon-sm" />
|
||||||
@@ -114,7 +123,7 @@ const BlacklistBlock = ({
|
|||||||
<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>
|
||||||
{intl.formatDate(blacklistItem.createdAt, {
|
{intl.formatDate(data.createdAt, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const BlacklistModal = ({
|
|||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const { data, error } = useSWR<TvDetails | MovieDetails>(
|
const { data, error } = useSWR<TvDetails | MovieDetails>(
|
||||||
`/api/v1/${type}/${tmdbId}`
|
show ? `/api/v1/${type}/${tmdbId}` : null
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useLockBodyScroll } from '@app/hooks/useLockBodyScroll';
|
|||||||
import globalMessages from '@app/i18n/globalMessages';
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
import { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import type { MouseEvent } from 'react';
|
import type { MouseEvent } from 'react';
|
||||||
import React, { Fragment, useRef } from 'react';
|
import React, { Fragment, useEffect, useRef } from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
@@ -66,8 +66,12 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
|
|||||||
) => {
|
) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const modalRef = useRef<HTMLDivElement>(null);
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const backgroundClickableRef = useRef(backgroundClickable); // This ref is used to detect state change inside the useClickOutside hook
|
||||||
|
useEffect(() => {
|
||||||
|
backgroundClickableRef.current = backgroundClickable;
|
||||||
|
}, [backgroundClickable]);
|
||||||
useClickOutside(modalRef, () => {
|
useClickOutside(modalRef, () => {
|
||||||
if (onCancel && backgroundClickable) {
|
if (onCancel && backgroundClickableRef.current) {
|
||||||
onCancel();
|
onCancel();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import defineMessages from '@app/utils/defineMessages';
|
|||||||
import type { TvResult } from '@server/models/Search';
|
import type { TvResult } from '@server/models/Search';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
const messages = defineMessages('components.DiscoverTvUpcoming', {});
|
const messages = defineMessages('components.DiscoverTvUpcoming', {
|
||||||
|
upcomingtv: 'Upcoming Series',
|
||||||
|
});
|
||||||
|
|
||||||
const DiscoverTvUpcoming = () => {
|
const DiscoverTvUpcoming = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|||||||
@@ -82,10 +82,17 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
|||||||
port: Yup.number().required(
|
port: Yup.number().required(
|
||||||
intl.formatMessage(messages.validationPortRequired)
|
intl.formatMessage(messages.validationPortRequired)
|
||||||
),
|
),
|
||||||
urlBase: Yup.string().matches(
|
urlBase: Yup.string()
|
||||||
/^(.*[^/])$/,
|
.test(
|
||||||
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
|
'leading-slash',
|
||||||
),
|
intl.formatMessage(messages.validationUrlBaseLeadingSlash),
|
||||||
|
(value) => !value || value.startsWith('/')
|
||||||
|
)
|
||||||
|
.test(
|
||||||
|
'trailing-slash',
|
||||||
|
intl.formatMessage(messages.validationUrlBaseTrailingSlash),
|
||||||
|
(value) => !value || !value.endsWith('/')
|
||||||
|
),
|
||||||
email: Yup.string()
|
email: Yup.string()
|
||||||
.email(intl.formatMessage(messages.validationemailformat))
|
.email(intl.formatMessage(messages.validationemailformat))
|
||||||
.required(intl.formatMessage(messages.validationemailrequired)),
|
.required(intl.formatMessage(messages.validationemailrequired)),
|
||||||
|
|||||||
@@ -292,7 +292,7 @@ const ManageSlideOver = ({
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
|
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
|
||||||
<BlacklistBlock
|
<BlacklistBlock
|
||||||
blacklistItem={data.mediaInfo.blacklist}
|
tmdbId={data.mediaInfo.tmdbId}
|
||||||
onUpdate={() => revalidate()}
|
onUpdate={() => revalidate()}
|
||||||
onDelete={() => onClose()}
|
onDelete={() => onClose()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -88,14 +88,14 @@ const SearchByNameModal = ({
|
|||||||
tvdbId === item.tvdbId ? 'ring ring-indigo-500' : ''
|
tvdbId === item.tvdbId ? 'ring ring-indigo-500' : ''
|
||||||
} `}
|
} `}
|
||||||
>
|
>
|
||||||
<div className="flex w-24 flex-none items-center space-x-4">
|
<div className="relative flex w-24 flex-none items-center space-x-4 self-stretch">
|
||||||
<Image
|
<Image
|
||||||
src={
|
src={
|
||||||
item.remotePoster ??
|
item.remotePoster ??
|
||||||
'/images/overseerr_poster_not_found.png'
|
'/images/overseerr_poster_not_found.png'
|
||||||
}
|
}
|
||||||
alt={item.title}
|
alt={item.title}
|
||||||
className="h-100 w-auto rounded-md"
|
className="w-100 h-auto rounded-md"
|
||||||
fill
|
fill
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
TmdbKeywordSearchResponse,
|
TmdbKeywordSearchResponse,
|
||||||
} from '@server/api/themoviedb/interfaces';
|
} from '@server/api/themoviedb/interfaces';
|
||||||
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
|
||||||
|
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
|
||||||
import type {
|
import type {
|
||||||
Keyword,
|
Keyword,
|
||||||
ProductionCompany,
|
ProductionCompany,
|
||||||
@@ -29,6 +30,7 @@ const messages = defineMessages('components.Selector', {
|
|||||||
searchKeywords: 'Search keywords…',
|
searchKeywords: 'Search keywords…',
|
||||||
searchGenres: 'Select genres…',
|
searchGenres: 'Select genres…',
|
||||||
searchStudios: 'Search studios…',
|
searchStudios: 'Search studios…',
|
||||||
|
searchUsers: 'Select users…',
|
||||||
starttyping: 'Starting typing to search.',
|
starttyping: 'Starting typing to search.',
|
||||||
nooptions: 'No results.',
|
nooptions: 'No results.',
|
||||||
showmore: 'Show More',
|
showmore: 'Show More',
|
||||||
@@ -437,7 +439,7 @@ export const WatchProviderSelector = ({
|
|||||||
key={`prodiver-${provider.id}`}
|
key={`prodiver-${provider.id}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 ${
|
className={`provider-container w-full cursor-pointer rounded-lg ring-1 ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
|
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
|
||||||
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
|
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
|
||||||
@@ -451,18 +453,15 @@ export const WatchProviderSelector = ({
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<CachedImage
|
<div className="relative m-2 aspect-1">
|
||||||
type="tmdb"
|
<CachedImage
|
||||||
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
type="tmdb"
|
||||||
alt=""
|
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
||||||
style={{
|
alt=""
|
||||||
width: '100%',
|
fill
|
||||||
height: '100%',
|
className="rounded-lg object-contain"
|
||||||
objectFit: 'contain',
|
/>
|
||||||
}}
|
</div>
|
||||||
fill
|
|
||||||
className="rounded-lg"
|
|
||||||
/>
|
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90">
|
<div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90">
|
||||||
<CheckCircleIcon className="h-6 w-6" />
|
<CheckCircleIcon className="h-6 w-6" />
|
||||||
@@ -483,7 +482,7 @@ export const WatchProviderSelector = ({
|
|||||||
key={`prodiver-${provider.id}`}
|
key={`prodiver-${provider.id}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
|
className={`provider-container w-full cursor-pointer rounded-lg ring-1 transition ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
|
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
|
||||||
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
|
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
|
||||||
@@ -497,18 +496,15 @@ export const WatchProviderSelector = ({
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<CachedImage
|
<div className="relative m-2 aspect-1">
|
||||||
type="tmdb"
|
<CachedImage
|
||||||
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
type="tmdb"
|
||||||
alt=""
|
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
|
||||||
style={{
|
alt=""
|
||||||
width: '100%',
|
fill
|
||||||
height: '100%',
|
className="rounded-lg object-contain"
|
||||||
objectFit: 'cover',
|
/>
|
||||||
}}
|
</div>
|
||||||
fill
|
|
||||||
className="rounded-lg"
|
|
||||||
/>
|
|
||||||
{isActive && (
|
{isActive && (
|
||||||
<div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90">
|
<div className="pointer-events-none absolute -top-1 -left-1 flex items-center justify-center text-indigo-100 opacity-90">
|
||||||
<CheckCircleIcon className="h-6 w-6" />
|
<CheckCircleIcon className="h-6 w-6" />
|
||||||
@@ -548,3 +544,77 @@ export const WatchProviderSelector = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const UserSelector = ({
|
||||||
|
isMulti,
|
||||||
|
defaultValue,
|
||||||
|
onChange,
|
||||||
|
}: BaseSelectorMultiProps | BaseSelectorSingleProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [defaultDataValue, setDefaultDataValue] = useState<
|
||||||
|
{ label: string; value: number }[] | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadUsers = async (): Promise<void> => {
|
||||||
|
if (!defaultValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = defaultValue.split(',');
|
||||||
|
|
||||||
|
const res = await fetch(`/api/v1/user`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Network response was not ok');
|
||||||
|
}
|
||||||
|
const response: UserResultsResponse = await res.json();
|
||||||
|
|
||||||
|
const genreData = users
|
||||||
|
.filter((u) => response.results.find((user) => user.id === Number(u)))
|
||||||
|
.map((u) => response.results.find((user) => user.id === Number(u)))
|
||||||
|
.map((u) => ({
|
||||||
|
label: u?.displayName ?? '',
|
||||||
|
value: u?.id ?? 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setDefaultDataValue(genreData);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadUsers();
|
||||||
|
}, [defaultValue]);
|
||||||
|
|
||||||
|
const loadUserOptions = async (inputValue: string) => {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/user${inputValue ? `?q=${encodeURIComponent(inputValue)}` : ''}`
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const results: UserResultsResponse = await res.json();
|
||||||
|
|
||||||
|
return results.results
|
||||||
|
.map((result) => ({
|
||||||
|
label: result.displayName,
|
||||||
|
value: result.id,
|
||||||
|
}))
|
||||||
|
.filter(({ label }) =>
|
||||||
|
label.toLowerCase().includes(inputValue.toLowerCase())
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AsyncSelect
|
||||||
|
key={`user-select-${defaultDataValue}`}
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
defaultValue={isMulti ? defaultDataValue : defaultDataValue?.[0]}
|
||||||
|
defaultOptions
|
||||||
|
cacheOptions
|
||||||
|
isMulti={isMulti}
|
||||||
|
loadOptions={loadUserOptions}
|
||||||
|
placeholder={intl.formatMessage(messages.searchUsers)}
|
||||||
|
onChange={(value) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
onChange(value as any);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -19,12 +19,16 @@ const messages = defineMessages('components.Settings.Notifications', {
|
|||||||
webhookUrl: 'Webhook URL',
|
webhookUrl: 'Webhook URL',
|
||||||
webhookUrlTip:
|
webhookUrlTip:
|
||||||
'Create a <DiscordWebhookLink>webhook integration</DiscordWebhookLink> in your server',
|
'Create a <DiscordWebhookLink>webhook integration</DiscordWebhookLink> in your server',
|
||||||
|
webhookRoleId: 'Notification Role ID',
|
||||||
|
webhookRoleIdTip:
|
||||||
|
'The role ID to mention in the webhook message. Leave empty to disable mentions',
|
||||||
discordsettingssaved: 'Discord notification settings saved successfully!',
|
discordsettingssaved: 'Discord notification settings saved successfully!',
|
||||||
discordsettingsfailed: 'Discord notification settings failed to save.',
|
discordsettingsfailed: 'Discord notification settings failed to save.',
|
||||||
toastDiscordTestSending: 'Sending Discord test notification…',
|
toastDiscordTestSending: 'Sending Discord test notification…',
|
||||||
toastDiscordTestSuccess: 'Discord test notification sent!',
|
toastDiscordTestSuccess: 'Discord test notification sent!',
|
||||||
toastDiscordTestFailed: 'Discord test notification failed to send.',
|
toastDiscordTestFailed: 'Discord test notification failed to send.',
|
||||||
validationUrl: 'You must provide a valid URL',
|
validationUrl: 'You must provide a valid URL',
|
||||||
|
validationWebhookRoleId: 'You must provide a valid Discord Role ID',
|
||||||
validationTypes: 'You must select at least one notification type',
|
validationTypes: 'You must select at least one notification type',
|
||||||
enableMentions: 'Enable Mentions',
|
enableMentions: 'Enable Mentions',
|
||||||
});
|
});
|
||||||
@@ -53,6 +57,12 @@ const NotificationsDiscord = () => {
|
|||||||
otherwise: Yup.string().nullable(),
|
otherwise: Yup.string().nullable(),
|
||||||
})
|
})
|
||||||
.url(intl.formatMessage(messages.validationUrl)),
|
.url(intl.formatMessage(messages.validationUrl)),
|
||||||
|
webhookRoleId: Yup.string()
|
||||||
|
.nullable()
|
||||||
|
.matches(
|
||||||
|
/^\d{17,19}$/,
|
||||||
|
intl.formatMessage(messages.validationWebhookRoleId)
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data && !error) {
|
if (!data && !error) {
|
||||||
@@ -67,6 +77,7 @@ const NotificationsDiscord = () => {
|
|||||||
botUsername: data?.options.botUsername,
|
botUsername: data?.options.botUsername,
|
||||||
botAvatarUrl: data?.options.botAvatarUrl,
|
botAvatarUrl: data?.options.botAvatarUrl,
|
||||||
webhookUrl: data.options.webhookUrl,
|
webhookUrl: data.options.webhookUrl,
|
||||||
|
webhookRoleId: data?.options.webhookRoleId,
|
||||||
enableMentions: data?.options.enableMentions,
|
enableMentions: data?.options.enableMentions,
|
||||||
}}
|
}}
|
||||||
validationSchema={NotificationsDiscordSchema}
|
validationSchema={NotificationsDiscordSchema}
|
||||||
@@ -84,6 +95,7 @@ const NotificationsDiscord = () => {
|
|||||||
botUsername: values.botUsername,
|
botUsername: values.botUsername,
|
||||||
botAvatarUrl: values.botAvatarUrl,
|
botAvatarUrl: values.botAvatarUrl,
|
||||||
webhookUrl: values.webhookUrl,
|
webhookUrl: values.webhookUrl,
|
||||||
|
webhookRoleId: values.webhookRoleId,
|
||||||
enableMentions: values.enableMentions,
|
enableMentions: values.enableMentions,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -141,6 +153,7 @@ const NotificationsDiscord = () => {
|
|||||||
botUsername: values.botUsername,
|
botUsername: values.botUsername,
|
||||||
botAvatarUrl: values.botAvatarUrl,
|
botAvatarUrl: values.botAvatarUrl,
|
||||||
webhookUrl: values.webhookUrl,
|
webhookUrl: values.webhookUrl,
|
||||||
|
webhookRoleId: values.webhookRoleId,
|
||||||
enableMentions: values.enableMentions,
|
enableMentions: values.enableMentions,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -254,6 +267,21 @@ const NotificationsDiscord = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="webhookRoleId" className="text-label">
|
||||||
|
{intl.formatMessage(messages.webhookRoleId)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field id="webhookRoleId" name="webhookRoleId" type="text" />
|
||||||
|
</div>
|
||||||
|
{errors.webhookRoleId &&
|
||||||
|
touched.webhookRoleId &&
|
||||||
|
typeof errors.webhookRoleId === 'string' && (
|
||||||
|
<div className="error">{errors.webhookRoleId}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label htmlFor="enableMentions" className="checkbox-label">
|
<label htmlFor="enableMentions" className="checkbox-label">
|
||||||
{intl.formatMessage(messages.enableMentions)}
|
{intl.formatMessage(messages.enableMentions)}
|
||||||
|
|||||||
391
src/components/Settings/OverrideRule/OverrideRuleModal.tsx
Normal file
391
src/components/Settings/OverrideRule/OverrideRuleModal.tsx
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
import Modal from '@app/components/Common/Modal';
|
||||||
|
import LanguageSelector from '@app/components/LanguageSelector';
|
||||||
|
import {
|
||||||
|
GenreSelector,
|
||||||
|
KeywordSelector,
|
||||||
|
UserSelector,
|
||||||
|
} from '@app/components/Selector';
|
||||||
|
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
|
||||||
|
import useSettings from '@app/hooks/useSettings';
|
||||||
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { Transition } from '@headlessui/react';
|
||||||
|
import type OverrideRule from '@server/entity/OverrideRule';
|
||||||
|
import { Field, Formik } from 'formik';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import Select from 'react-select';
|
||||||
|
import { useToasts } from 'react-toast-notifications';
|
||||||
|
|
||||||
|
const messages = defineMessages('components.Settings.RadarrModal', {
|
||||||
|
createrule: 'New Override Rule',
|
||||||
|
editrule: 'Edit Override Rule',
|
||||||
|
create: 'Create rule',
|
||||||
|
conditions: 'Conditions',
|
||||||
|
conditionsDescription:
|
||||||
|
'Specifies conditions before applying parameter changes. Each field must be validated for the rules to be applied (AND operation). A field is considered verified if any of its properties match (OR operation).',
|
||||||
|
settings: 'Settings',
|
||||||
|
settingsDescription:
|
||||||
|
'Specifies which settings will be changed when the above conditions are met.',
|
||||||
|
users: 'Users',
|
||||||
|
genres: 'Genres',
|
||||||
|
languages: 'Languages',
|
||||||
|
keywords: 'Keywords',
|
||||||
|
rootfolder: 'Root Folder',
|
||||||
|
selectRootFolder: 'Select root folder',
|
||||||
|
qualityprofile: 'Quality Profile',
|
||||||
|
selectQualityProfile: 'Select quality profile',
|
||||||
|
tags: 'Tags',
|
||||||
|
notagoptions: 'No tags.',
|
||||||
|
selecttags: 'Select tags',
|
||||||
|
ruleCreated: 'Override rule created successfully!',
|
||||||
|
ruleUpdated: 'Override rule updated successfully!',
|
||||||
|
});
|
||||||
|
|
||||||
|
type OptionType = {
|
||||||
|
value: number;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface OverrideRuleModalProps {
|
||||||
|
rule: OverrideRule | null;
|
||||||
|
onClose: () => void;
|
||||||
|
testResponse: DVRTestResponse;
|
||||||
|
radarrId?: number;
|
||||||
|
sonarrId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OverrideRuleModal = ({
|
||||||
|
onClose,
|
||||||
|
rule,
|
||||||
|
testResponse,
|
||||||
|
radarrId,
|
||||||
|
sonarrId,
|
||||||
|
}: OverrideRuleModalProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { addToast } = useToasts();
|
||||||
|
const { currentSettings } = useSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition
|
||||||
|
as="div"
|
||||||
|
appear
|
||||||
|
show
|
||||||
|
enter="transition-opacity ease-in-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="transition-opacity ease-in-out duration-300"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
users: rule?.users,
|
||||||
|
genre: rule?.genre,
|
||||||
|
language: rule?.language,
|
||||||
|
keywords: rule?.keywords,
|
||||||
|
profileId: rule?.profileId,
|
||||||
|
rootFolder: rule?.rootFolder,
|
||||||
|
tags: rule?.tags,
|
||||||
|
}}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
try {
|
||||||
|
const submission = {
|
||||||
|
users: values.users || null,
|
||||||
|
genre: values.genre || null,
|
||||||
|
language: values.language || null,
|
||||||
|
keywords: values.keywords || null,
|
||||||
|
profileId: Number(values.profileId) || null,
|
||||||
|
rootFolder: values.rootFolder || null,
|
||||||
|
tags: values.tags || null,
|
||||||
|
radarrServiceId: radarrId,
|
||||||
|
sonarrServiceId: sonarrId,
|
||||||
|
};
|
||||||
|
if (!rule) {
|
||||||
|
const res = await fetch('/api/v1/overrideRule', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(submission),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
addToast(intl.formatMessage(messages.ruleCreated), {
|
||||||
|
appearance: 'success',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const res = await fetch(`/api/v1/overrideRule/${rule.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(submission),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
addToast(intl.formatMessage(messages.ruleUpdated), {
|
||||||
|
appearance: 'success',
|
||||||
|
autoDismiss: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
// set error here
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
values,
|
||||||
|
handleSubmit,
|
||||||
|
setFieldValue,
|
||||||
|
isSubmitting,
|
||||||
|
isValid,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
onCancel={onClose}
|
||||||
|
okButtonType="primary"
|
||||||
|
okText={
|
||||||
|
isSubmitting
|
||||||
|
? intl.formatMessage(globalMessages.saving)
|
||||||
|
: rule
|
||||||
|
? intl.formatMessage(globalMessages.save)
|
||||||
|
: intl.formatMessage(messages.create)
|
||||||
|
}
|
||||||
|
okDisabled={
|
||||||
|
isSubmitting ||
|
||||||
|
!isValid ||
|
||||||
|
(!values.users &&
|
||||||
|
!values.genre &&
|
||||||
|
!values.language &&
|
||||||
|
!values.keywords) ||
|
||||||
|
(!values.rootFolder && !values.profileId && !values.tags)
|
||||||
|
}
|
||||||
|
onOk={() => handleSubmit()}
|
||||||
|
title={
|
||||||
|
!rule
|
||||||
|
? intl.formatMessage(messages.createrule)
|
||||||
|
: intl.formatMessage(messages.editrule)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-bold leading-8 text-gray-100">
|
||||||
|
{intl.formatMessage(messages.conditions)}
|
||||||
|
</h3>
|
||||||
|
<p className="description">
|
||||||
|
{intl.formatMessage(messages.conditionsDescription)}
|
||||||
|
</p>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="users" className="text-label">
|
||||||
|
{intl.formatMessage(messages.users)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<UserSelector
|
||||||
|
defaultValue={values.users}
|
||||||
|
isMulti
|
||||||
|
onChange={(users) => {
|
||||||
|
setFieldValue(
|
||||||
|
'users',
|
||||||
|
users?.map((v) => v.value).join(',')
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.users &&
|
||||||
|
touched.users &&
|
||||||
|
typeof errors.users === 'string' && (
|
||||||
|
<div className="error">{errors.users}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="genre" className="text-label">
|
||||||
|
{intl.formatMessage(messages.genres)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<GenreSelector
|
||||||
|
type={radarrId ? 'movie' : 'tv'}
|
||||||
|
defaultValue={values.genre}
|
||||||
|
isMulti
|
||||||
|
onChange={(genres) => {
|
||||||
|
setFieldValue(
|
||||||
|
'genre',
|
||||||
|
genres?.map((v) => v.value).join(',')
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.genre &&
|
||||||
|
touched.genre &&
|
||||||
|
typeof errors.genre === 'string' && (
|
||||||
|
<div className="error">{errors.genre}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="language" className="text-label">
|
||||||
|
{intl.formatMessage(messages.languages)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<LanguageSelector
|
||||||
|
value={values.language}
|
||||||
|
serverValue={currentSettings.originalLanguage}
|
||||||
|
setFieldValue={(_key, value) => {
|
||||||
|
setFieldValue('language', value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.language &&
|
||||||
|
touched.language &&
|
||||||
|
typeof errors.language === 'string' && (
|
||||||
|
<div className="error">{errors.language}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="keywords" className="text-label">
|
||||||
|
{intl.formatMessage(messages.keywords)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<KeywordSelector
|
||||||
|
defaultValue={values.keywords}
|
||||||
|
isMulti
|
||||||
|
onChange={(value) => {
|
||||||
|
setFieldValue(
|
||||||
|
'keywords',
|
||||||
|
value?.map((v) => v.value).join(',')
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.keywords &&
|
||||||
|
touched.keywords &&
|
||||||
|
typeof errors.keywords === 'string' && (
|
||||||
|
<div className="error">{errors.keywords}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-4 text-lg font-bold leading-8 text-gray-100">
|
||||||
|
{intl.formatMessage(messages.settings)}
|
||||||
|
</h3>
|
||||||
|
<p className="description">
|
||||||
|
{intl.formatMessage(messages.settingsDescription)}
|
||||||
|
</p>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="rootFolderRule" className="text-label">
|
||||||
|
{intl.formatMessage(messages.rootfolder)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field as="select" id="rootFolderRule" name="rootFolder">
|
||||||
|
<option value="">
|
||||||
|
{intl.formatMessage(messages.selectRootFolder)}
|
||||||
|
</option>
|
||||||
|
{testResponse.rootFolders.length > 0 &&
|
||||||
|
testResponse.rootFolders.map((folder) => (
|
||||||
|
<option
|
||||||
|
key={`loaded-profile-${folder.id}`}
|
||||||
|
value={folder.path}
|
||||||
|
>
|
||||||
|
{folder.path}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{errors.rootFolder &&
|
||||||
|
touched.rootFolder &&
|
||||||
|
typeof errors.rootFolder === 'string' && (
|
||||||
|
<div className="error">{errors.rootFolder}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="profileIdRule" className="text-label">
|
||||||
|
{intl.formatMessage(messages.qualityprofile)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<div className="form-input-field">
|
||||||
|
<Field as="select" id="profileIdRule" name="profileId">
|
||||||
|
<option value="">
|
||||||
|
{intl.formatMessage(messages.selectQualityProfile)}
|
||||||
|
</option>
|
||||||
|
{testResponse.profiles.length > 0 &&
|
||||||
|
testResponse.profiles.map((profile) => (
|
||||||
|
<option
|
||||||
|
key={`loaded-profile-${profile.id}`}
|
||||||
|
value={profile.id}
|
||||||
|
>
|
||||||
|
{profile.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{errors.profileId &&
|
||||||
|
touched.profileId &&
|
||||||
|
typeof errors.profileId === 'string' && (
|
||||||
|
<div className="error">{errors.profileId}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label htmlFor="tags" className="text-label">
|
||||||
|
{intl.formatMessage(messages.tags)}
|
||||||
|
</label>
|
||||||
|
<div className="form-input-area">
|
||||||
|
<Select<OptionType, true>
|
||||||
|
options={testResponse.tags.map((tag) => ({
|
||||||
|
label: tag.label,
|
||||||
|
value: tag.id,
|
||||||
|
}))}
|
||||||
|
isMulti
|
||||||
|
placeholder={intl.formatMessage(messages.selecttags)}
|
||||||
|
className="react-select-container"
|
||||||
|
classNamePrefix="react-select"
|
||||||
|
value={
|
||||||
|
(values?.tags
|
||||||
|
?.split(',')
|
||||||
|
.map((tagId) => {
|
||||||
|
const foundTag = testResponse.tags.find(
|
||||||
|
(tag) => tag.id === Number(tagId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!foundTag) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: foundTag.id,
|
||||||
|
label: foundTag.label,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(option) => option !== undefined
|
||||||
|
) as OptionType[]) || []
|
||||||
|
}
|
||||||
|
onChange={(value) => {
|
||||||
|
setFieldValue(
|
||||||
|
'tags',
|
||||||
|
value.map((option) => option.value).join(',')
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
noOptionsMessage={() =>
|
||||||
|
intl.formatMessage(messages.notagoptions)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OverrideRuleModal;
|
||||||
267
src/components/Settings/OverrideRule/OverrideRuleTile.tsx
Normal file
267
src/components/Settings/OverrideRule/OverrideRuleTile.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
|
||||||
|
import globalMessages from '@app/i18n/globalMessages';
|
||||||
|
import defineMessages from '@app/utils/defineMessages';
|
||||||
|
import { PencilIcon, TrashIcon } from '@heroicons/react/24/solid';
|
||||||
|
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
|
||||||
|
import type OverrideRule from '@server/entity/OverrideRule';
|
||||||
|
import type { User } from '@server/entity/User';
|
||||||
|
import type {
|
||||||
|
Language,
|
||||||
|
RadarrSettings,
|
||||||
|
SonarrSettings,
|
||||||
|
} from '@server/lib/settings';
|
||||||
|
import type { Keyword } from '@server/models/common';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
const messages = defineMessages('components.Settings.OverrideRuleTile', {
|
||||||
|
qualityprofile: 'Quality Profile',
|
||||||
|
rootfolder: 'Root Folder',
|
||||||
|
tags: 'Tags',
|
||||||
|
users: 'Users',
|
||||||
|
genre: 'Genre',
|
||||||
|
language: 'Language',
|
||||||
|
keywords: 'Keywords',
|
||||||
|
conditions: 'Conditions',
|
||||||
|
settings: 'Settings',
|
||||||
|
});
|
||||||
|
|
||||||
|
interface OverrideRuleTileProps {
|
||||||
|
rules: OverrideRule[];
|
||||||
|
setOverrideRuleModal: ({
|
||||||
|
open,
|
||||||
|
rule,
|
||||||
|
testResponse,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
rule: OverrideRule | null;
|
||||||
|
testResponse: DVRTestResponse;
|
||||||
|
}) => void;
|
||||||
|
testResponse: DVRTestResponse;
|
||||||
|
radarr?: RadarrSettings | null;
|
||||||
|
sonarr?: SonarrSettings | null;
|
||||||
|
revalidate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OverrideRuleTile = ({
|
||||||
|
rules,
|
||||||
|
setOverrideRuleModal,
|
||||||
|
testResponse,
|
||||||
|
radarr,
|
||||||
|
sonarr,
|
||||||
|
revalidate,
|
||||||
|
}: OverrideRuleTileProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [users, setUsers] = useState<User[] | null>(null);
|
||||||
|
const [keywords, setKeywords] = useState<Keyword[] | null>(null);
|
||||||
|
const { data: languages } = useSWR<Language[]>('/api/v1/languages');
|
||||||
|
const { data: genres } = useSWR<TmdbGenre[]>('/api/v1/genres/movie');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const keywords = await Promise.all(
|
||||||
|
rules
|
||||||
|
.map((rule) => rule.keywords?.split(','))
|
||||||
|
.flat()
|
||||||
|
.filter((keywordId) => keywordId)
|
||||||
|
.map(async (keywordId) => {
|
||||||
|
const res = await fetch(`/api/v1/keyword/${keywordId}`);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const keyword: Keyword = await res.json();
|
||||||
|
return keyword;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setKeywords(keywords);
|
||||||
|
const users = await Promise.all(
|
||||||
|
rules
|
||||||
|
.map((rule) => rule.users?.split(','))
|
||||||
|
.flat()
|
||||||
|
.filter((userId) => userId)
|
||||||
|
.map(async (userId) => {
|
||||||
|
const res = await fetch(`/api/v1/user/${userId}`);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const user: User = await res.json();
|
||||||
|
return user;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setUsers(users);
|
||||||
|
})();
|
||||||
|
}, [rules]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{rules
|
||||||
|
.filter(
|
||||||
|
(rule) =>
|
||||||
|
(rule.radarrServiceId !== null &&
|
||||||
|
rule.radarrServiceId === radarr?.id) ||
|
||||||
|
(rule.sonarrServiceId !== null &&
|
||||||
|
rule.sonarrServiceId === sonarr?.id)
|
||||||
|
)
|
||||||
|
.map((rule) => (
|
||||||
|
<li className="flex h-full flex-col rounded-lg bg-gray-800 text-left shadow ring-1 ring-gray-500">
|
||||||
|
<div className="flex w-full flex-1 items-center justify-between space-x-6 p-6">
|
||||||
|
<div className="flex-1 truncate">
|
||||||
|
<span className="text-lg">
|
||||||
|
{intl.formatMessage(messages.conditions)}
|
||||||
|
</span>
|
||||||
|
{rule.users && (
|
||||||
|
<p className="truncate text-sm leading-5 text-gray-300">
|
||||||
|
<span className="mr-2 font-bold">
|
||||||
|
{intl.formatMessage(messages.users)}
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
{rule.users.split(',').map((userId) => {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{
|
||||||
|
users?.find((user) => user.id === Number(userId))
|
||||||
|
?.displayName
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{rule.genre && (
|
||||||
|
<p className="truncate text-sm leading-5 text-gray-300">
|
||||||
|
<span className="mr-2 font-bold">
|
||||||
|
{intl.formatMessage(messages.genre)}
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
{rule.genre.split(',').map((genreId) => (
|
||||||
|
<span>
|
||||||
|
{genres?.find((g) => g.id === Number(genreId))?.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{rule.language && (
|
||||||
|
<p className="truncate text-sm leading-5 text-gray-300">
|
||||||
|
<span className="mr-2 font-bold">
|
||||||
|
{intl.formatMessage(messages.language)}
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
{rule.language
|
||||||
|
.split('|')
|
||||||
|
.filter((languageId) => languageId !== 'server')
|
||||||
|
.map((languageId) => {
|
||||||
|
const language = languages?.find(
|
||||||
|
(language) => language.iso_639_1 === languageId
|
||||||
|
);
|
||||||
|
if (!language) return null;
|
||||||
|
const languageName =
|
||||||
|
intl.formatDisplayName(language.iso_639_1, {
|
||||||
|
type: 'language',
|
||||||
|
fallback: 'none',
|
||||||
|
}) ?? language.english_name;
|
||||||
|
return <span>{languageName}</span>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{rule.keywords && (
|
||||||
|
<p className="truncate text-sm leading-5 text-gray-300">
|
||||||
|
<span className="mr-2 font-bold">
|
||||||
|
{intl.formatMessage(messages.keywords)}
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
{rule.keywords.split(',').map((keywordId) => {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{
|
||||||
|
keywords?.find(
|
||||||
|
(keyword) => keyword.id === Number(keywordId)
|
||||||
|
)?.name
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<span className="text-lg">
|
||||||
|
{intl.formatMessage(messages.settings)}
|
||||||
|
</span>
|
||||||
|
{rule.profileId && (
|
||||||
|
<p className="runcate text-sm leading-5 text-gray-300">
|
||||||
|
<span className="mr-2 font-bold">
|
||||||
|
{intl.formatMessage(messages.qualityprofile)}
|
||||||
|
</span>
|
||||||
|
{
|
||||||
|
testResponse.profiles.find(
|
||||||
|
(profile) => rule.profileId === profile.id
|
||||||
|
)?.name
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{rule.rootFolder && (
|
||||||
|
<p className="truncate text-sm leading-5 text-gray-300">
|
||||||
|
<span className="mr-2 font-bold">
|
||||||
|
{intl.formatMessage(messages.rootfolder)}
|
||||||
|
</span>
|
||||||
|
{rule.rootFolder}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{rule.tags && rule.tags.length > 0 && (
|
||||||
|
<p className="truncate text-sm leading-5 text-gray-300">
|
||||||
|
<span className="mr-2 font-bold">
|
||||||
|
{intl.formatMessage(messages.tags)}
|
||||||
|
</span>
|
||||||
|
<div className="inline-flex gap-2">
|
||||||
|
{rule.tags.split(',').map((tag) => (
|
||||||
|
<span>
|
||||||
|
{
|
||||||
|
testResponse.tags?.find((t) => t.id === Number(tag))
|
||||||
|
?.label
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-gray-500">
|
||||||
|
<div className="-mt-px flex">
|
||||||
|
<div className="flex w-0 flex-1 border-r border-gray-500">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setOverrideRuleModal({ open: true, rule, testResponse })
|
||||||
|
}
|
||||||
|
className="focus:ring-blue relative -mr-px inline-flex w-0 flex-1 items-center justify-center rounded-bl-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<PencilIcon className="mr-2 h-5 w-5" />
|
||||||
|
<span>{intl.formatMessage(globalMessages.edit)}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="-ml-px flex w-0 flex-1">
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/overrideRule/${rule.id}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
revalidate();
|
||||||
|
}}
|
||||||
|
className="focus:ring-blue relative inline-flex w-0 flex-1 items-center justify-center rounded-br-lg border border-transparent py-4 text-sm font-medium leading-5 text-gray-200 transition duration-150 ease-in-out hover:text-white focus:z-10 focus:border-gray-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<TrashIcon className="mr-2 h-5 w-5" />
|
||||||
|
<span>{intl.formatMessage(globalMessages.delete)}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OverrideRuleTile;
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
|
import Button from '@app/components/Common/Button';
|
||||||
import Modal from '@app/components/Common/Modal';
|
import Modal from '@app/components/Common/Modal';
|
||||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||||
|
import OverrideRuleTile from '@app/components/Settings/OverrideRule/OverrideRuleTile';
|
||||||
|
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
|
||||||
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 { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
|
import { PlusIcon } from '@heroicons/react/24/solid';
|
||||||
|
import type OverrideRule from '@server/entity/OverrideRule';
|
||||||
|
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
|
||||||
import type { RadarrSettings } from '@server/lib/settings';
|
import type { RadarrSettings } from '@server/lib/settings';
|
||||||
import { Field, Formik } from 'formik';
|
import { Field, Formik } from 'formik';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
import Select from 'react-select';
|
import Select from 'react-select';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
|
import useSWR from 'swr';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
type OptionType = {
|
type OptionType = {
|
||||||
@@ -69,41 +76,46 @@ const messages = defineMessages('components.Settings.RadarrModal', {
|
|||||||
announced: 'Announced',
|
announced: 'Announced',
|
||||||
inCinemas: 'In Cinemas',
|
inCinemas: 'In Cinemas',
|
||||||
released: 'Released',
|
released: 'Released',
|
||||||
|
overrideRules: 'Override Rules',
|
||||||
|
addrule: 'New Override Rule',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface TestResponse {
|
|
||||||
profiles: {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
}[];
|
|
||||||
rootFolders: {
|
|
||||||
id: number;
|
|
||||||
path: string;
|
|
||||||
}[];
|
|
||||||
tags: {
|
|
||||||
id: number;
|
|
||||||
label: string;
|
|
||||||
}[];
|
|
||||||
urlBase?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RadarrModalProps {
|
interface RadarrModalProps {
|
||||||
radarr: RadarrSettings | null;
|
radarr: RadarrSettings | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
|
overrideRuleModal: { open: boolean; rule: OverrideRule | null };
|
||||||
|
setOverrideRuleModal: ({
|
||||||
|
open,
|
||||||
|
rule,
|
||||||
|
testResponse,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
rule: OverrideRule | null;
|
||||||
|
testResponse: DVRTestResponse;
|
||||||
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
const RadarrModal = ({
|
||||||
|
onClose,
|
||||||
|
radarr,
|
||||||
|
onSave,
|
||||||
|
overrideRuleModal,
|
||||||
|
setOverrideRuleModal,
|
||||||
|
}: RadarrModalProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const { data: rules, mutate: revalidate } =
|
||||||
|
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
|
||||||
const initialLoad = useRef(false);
|
const initialLoad = useRef(false);
|
||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
const [isValidated, setIsValidated] = useState(radarr ? true : false);
|
const [isValidated, setIsValidated] = useState(radarr ? true : false);
|
||||||
const [isTesting, setIsTesting] = useState(false);
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
const [testResponse, setTestResponse] = useState<TestResponse>({
|
const [testResponse, setTestResponse] = useState<DVRTestResponse>({
|
||||||
profiles: [],
|
profiles: [],
|
||||||
rootFolders: [],
|
rootFolders: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const RadarrSettingsSchema = Yup.object().shape({
|
const RadarrSettingsSchema = Yup.object().shape({
|
||||||
name: Yup.string().required(
|
name: Yup.string().required(
|
||||||
intl.formatMessage(messages.validationNameRequired)
|
intl.formatMessage(messages.validationNameRequired)
|
||||||
@@ -130,7 +142,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
|||||||
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
|
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
|
||||||
),
|
),
|
||||||
externalUrl: Yup.string()
|
externalUrl: Yup.string()
|
||||||
.url(intl.formatMessage(messages.validationApplicationUrl))
|
.matches(
|
||||||
|
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
|
||||||
|
intl.formatMessage(messages.validationApplicationUrl)
|
||||||
|
)
|
||||||
.test(
|
.test(
|
||||||
'no-trailing-slash',
|
'no-trailing-slash',
|
||||||
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
||||||
@@ -217,6 +232,10 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
|||||||
}
|
}
|
||||||
}, [radarr, testConnection]);
|
}, [radarr, testConnection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
revalidate();
|
||||||
|
}, [overrideRuleModal, revalidate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
@@ -360,6 +379,7 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
|||||||
values.is4k ? messages.edit4kradarr : messages.editradarr
|
values.is4k ? messages.edit4kradarr : messages.editradarr
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
backgroundClickable={!overrideRuleModal.open}
|
||||||
>
|
>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
@@ -750,6 +770,38 @@ const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<h3 className="mb-4 text-xl font-bold leading-8 text-gray-100">
|
||||||
|
{intl.formatMessage(messages.overrideRules)}
|
||||||
|
</h3>
|
||||||
|
<ul className="grid grid-cols-2 gap-6">
|
||||||
|
{rules && (
|
||||||
|
<OverrideRuleTile
|
||||||
|
rules={rules}
|
||||||
|
setOverrideRuleModal={setOverrideRuleModal}
|
||||||
|
testResponse={testResponse}
|
||||||
|
radarr={radarr}
|
||||||
|
revalidate={revalidate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<li className="min-h-[8rem] rounded-lg border-2 border-dashed border-gray-400 shadow sm:min-h-[11rem]">
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<Button
|
||||||
|
buttonType="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
setOverrideRuleModal({
|
||||||
|
open: true,
|
||||||
|
rule: null,
|
||||||
|
testResponse,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!isValidated}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
<span>{intl.formatMessage(messages.addrule)}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -139,7 +139,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
),
|
),
|
||||||
jellyfinExternalUrl: Yup.string()
|
jellyfinExternalUrl: Yup.string()
|
||||||
.nullable()
|
.nullable()
|
||||||
.url(intl.formatMessage(messages.validationUrl))
|
.matches(
|
||||||
|
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
|
||||||
|
intl.formatMessage(messages.validationUrl)
|
||||||
|
)
|
||||||
.test(
|
.test(
|
||||||
'no-trailing-slash',
|
'no-trailing-slash',
|
||||||
intl.formatMessage(messages.validationUrlTrailingSlash),
|
intl.formatMessage(messages.validationUrlTrailingSlash),
|
||||||
@@ -147,7 +150,10 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
|
|||||||
),
|
),
|
||||||
jellyfinForgotPasswordUrl: Yup.string()
|
jellyfinForgotPasswordUrl: Yup.string()
|
||||||
.nullable()
|
.nullable()
|
||||||
.url(intl.formatMessage(messages.validationUrl))
|
.matches(
|
||||||
|
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
|
||||||
|
intl.formatMessage(messages.validationUrl)
|
||||||
|
)
|
||||||
.test(
|
.test(
|
||||||
'no-trailing-slash',
|
'no-trailing-slash',
|
||||||
intl.formatMessage(messages.validationUrlTrailingSlash),
|
intl.formatMessage(messages.validationUrlTrailingSlash),
|
||||||
|
|||||||
@@ -87,7 +87,10 @@ const SettingsMain = () => {
|
|||||||
intl.formatMessage(messages.validationApplicationTitle)
|
intl.formatMessage(messages.validationApplicationTitle)
|
||||||
),
|
),
|
||||||
applicationUrl: Yup.string()
|
applicationUrl: Yup.string()
|
||||||
.url(intl.formatMessage(messages.validationApplicationUrl))
|
.matches(
|
||||||
|
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
|
||||||
|
intl.formatMessage(messages.validationApplicationUrl)
|
||||||
|
)
|
||||||
.test(
|
.test(
|
||||||
'no-trailing-slash',
|
'no-trailing-slash',
|
||||||
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
||||||
|
|||||||
@@ -190,7 +190,10 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
|
|||||||
otherwise: Yup.string().nullable(),
|
otherwise: Yup.string().nullable(),
|
||||||
}),
|
}),
|
||||||
tautulliExternalUrl: Yup.string()
|
tautulliExternalUrl: Yup.string()
|
||||||
.url(intl.formatMessage(messages.validationUrl))
|
.matches(
|
||||||
|
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
|
||||||
|
intl.formatMessage(messages.validationUrl)
|
||||||
|
)
|
||||||
.test(
|
.test(
|
||||||
'no-trailing-slash',
|
'no-trailing-slash',
|
||||||
intl.formatMessage(messages.validationUrlTrailingSlash),
|
intl.formatMessage(messages.validationUrlTrailingSlash),
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import Button from '@app/components/Common/Button';
|
|||||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||||
import Modal from '@app/components/Common/Modal';
|
import Modal from '@app/components/Common/Modal';
|
||||||
import PageTitle from '@app/components/Common/PageTitle';
|
import PageTitle from '@app/components/Common/PageTitle';
|
||||||
|
import OverrideRuleModal from '@app/components/Settings/OverrideRule/OverrideRuleModal';
|
||||||
import RadarrModal from '@app/components/Settings/RadarrModal';
|
import RadarrModal from '@app/components/Settings/RadarrModal';
|
||||||
import SonarrModal from '@app/components/Settings/SonarrModal';
|
import SonarrModal from '@app/components/Settings/SonarrModal';
|
||||||
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 { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid';
|
import { PencilIcon, PlusIcon, TrashIcon } from '@heroicons/react/24/solid';
|
||||||
|
import type OverrideRule from '@server/entity/OverrideRule';
|
||||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||||
import { Fragment, useState } from 'react';
|
import { Fragment, useState } from 'react';
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
@@ -57,6 +59,22 @@ interface ServerInstanceProps {
|
|||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DVRTestResponse {
|
||||||
|
profiles: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}[];
|
||||||
|
rootFolders: {
|
||||||
|
id: number;
|
||||||
|
path: string;
|
||||||
|
}[];
|
||||||
|
tags: {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
}[];
|
||||||
|
urlBase?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const ServerInstance = ({
|
const ServerInstance = ({
|
||||||
name,
|
name,
|
||||||
hostname,
|
hostname,
|
||||||
@@ -193,6 +211,15 @@ const SettingsServices = () => {
|
|||||||
type: 'radarr',
|
type: 'radarr',
|
||||||
serverId: null,
|
serverId: null,
|
||||||
});
|
});
|
||||||
|
const [overrideRuleModal, setOverrideRuleModal] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
rule: OverrideRule | null;
|
||||||
|
testResponse: DVRTestResponse | null;
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
rule: null,
|
||||||
|
testResponse: null,
|
||||||
|
});
|
||||||
|
|
||||||
const deleteServer = async () => {
|
const deleteServer = async () => {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
@@ -227,26 +254,51 @@ const SettingsServices = () => {
|
|||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{overrideRuleModal.open && overrideRuleModal.testResponse && (
|
||||||
|
<OverrideRuleModal
|
||||||
|
rule={overrideRuleModal.rule}
|
||||||
|
onClose={() =>
|
||||||
|
setOverrideRuleModal({
|
||||||
|
open: false,
|
||||||
|
rule: null,
|
||||||
|
testResponse: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
testResponse={overrideRuleModal.testResponse}
|
||||||
|
radarrId={editRadarrModal.radarr?.id}
|
||||||
|
sonarrId={editSonarrModal.sonarr?.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{editRadarrModal.open && (
|
{editRadarrModal.open && (
|
||||||
<RadarrModal
|
<RadarrModal
|
||||||
radarr={editRadarrModal.radarr}
|
radarr={editRadarrModal.radarr}
|
||||||
onClose={() => setEditRadarrModal({ open: false, radarr: null })}
|
onClose={() => {
|
||||||
|
if (!overrideRuleModal.open)
|
||||||
|
setEditRadarrModal({ open: false, radarr: null });
|
||||||
|
}}
|
||||||
onSave={() => {
|
onSave={() => {
|
||||||
revalidateRadarr();
|
revalidateRadarr();
|
||||||
mutate('/api/v1/settings/public');
|
mutate('/api/v1/settings/public');
|
||||||
setEditRadarrModal({ open: false, radarr: null });
|
setEditRadarrModal({ open: false, radarr: null });
|
||||||
}}
|
}}
|
||||||
|
overrideRuleModal={overrideRuleModal}
|
||||||
|
setOverrideRuleModal={setOverrideRuleModal}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{editSonarrModal.open && (
|
{editSonarrModal.open && (
|
||||||
<SonarrModal
|
<SonarrModal
|
||||||
sonarr={editSonarrModal.sonarr}
|
sonarr={editSonarrModal.sonarr}
|
||||||
onClose={() => setEditSonarrModal({ open: false, sonarr: null })}
|
onClose={() => {
|
||||||
|
if (!overrideRuleModal.open)
|
||||||
|
setEditSonarrModal({ open: false, sonarr: null });
|
||||||
|
}}
|
||||||
onSave={() => {
|
onSave={() => {
|
||||||
revalidateSonarr();
|
revalidateSonarr();
|
||||||
mutate('/api/v1/settings/public');
|
mutate('/api/v1/settings/public');
|
||||||
setEditSonarrModal({ open: false, sonarr: null });
|
setEditSonarrModal({ open: false, sonarr: null });
|
||||||
}}
|
}}
|
||||||
|
overrideRuleModal={overrideRuleModal}
|
||||||
|
setOverrideRuleModal={setOverrideRuleModal}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Transition
|
<Transition
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
import Button from '@app/components/Common/Button';
|
||||||
import Modal from '@app/components/Common/Modal';
|
import Modal from '@app/components/Common/Modal';
|
||||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||||
|
import OverrideRuleTile from '@app/components/Settings/OverrideRule/OverrideRuleTile';
|
||||||
|
import type { DVRTestResponse } from '@app/components/Settings/SettingsServices';
|
||||||
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 { Transition } from '@headlessui/react';
|
import { Transition } from '@headlessui/react';
|
||||||
|
import { PlusIcon } from '@heroicons/react/24/solid';
|
||||||
|
import type OverrideRule from '@server/entity/OverrideRule';
|
||||||
|
import type { OverrideRuleResultsResponse } from '@server/interfaces/api/overrideRuleInterfaces';
|
||||||
import type { SonarrSettings } from '@server/lib/settings';
|
import type { SonarrSettings } from '@server/lib/settings';
|
||||||
import { Field, Formik } from 'formik';
|
import { Field, Formik } from 'formik';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
@@ -10,6 +16,7 @@ import { useIntl } from 'react-intl';
|
|||||||
import type { OnChangeValue } from 'react-select';
|
import type { OnChangeValue } from 'react-select';
|
||||||
import Select from 'react-select';
|
import Select from 'react-select';
|
||||||
import { useToasts } from 'react-toast-notifications';
|
import { useToasts } from 'react-toast-notifications';
|
||||||
|
import useSWR from 'swr';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
type OptionType = {
|
type OptionType = {
|
||||||
@@ -75,48 +82,56 @@ const messages = defineMessages('components.Settings.SonarrModal', {
|
|||||||
animeTags: 'Anime Tags',
|
animeTags: 'Anime Tags',
|
||||||
notagoptions: 'No tags.',
|
notagoptions: 'No tags.',
|
||||||
selecttags: 'Select tags',
|
selecttags: 'Select tags',
|
||||||
|
overrideRules: 'Override Rules',
|
||||||
|
addrule: 'New Override Rule',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface TestResponse {
|
interface SonarrTestResponse extends DVRTestResponse {
|
||||||
profiles: {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
}[];
|
|
||||||
rootFolders: {
|
|
||||||
id: number;
|
|
||||||
path: string;
|
|
||||||
}[];
|
|
||||||
languageProfiles:
|
languageProfiles:
|
||||||
| {
|
| {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
}[]
|
}[]
|
||||||
| null;
|
| null;
|
||||||
tags: {
|
|
||||||
id: number;
|
|
||||||
label: string;
|
|
||||||
}[];
|
|
||||||
urlBase?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SonarrModalProps {
|
interface SonarrModalProps {
|
||||||
sonarr: SonarrSettings | null;
|
sonarr: SonarrSettings | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
|
overrideRuleModal: { open: boolean; rule: OverrideRule | null };
|
||||||
|
setOverrideRuleModal: ({
|
||||||
|
open,
|
||||||
|
rule,
|
||||||
|
testResponse,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
rule: OverrideRule | null;
|
||||||
|
testResponse: DVRTestResponse;
|
||||||
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
const SonarrModal = ({
|
||||||
|
onClose,
|
||||||
|
sonarr,
|
||||||
|
onSave,
|
||||||
|
overrideRuleModal,
|
||||||
|
setOverrideRuleModal,
|
||||||
|
}: SonarrModalProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const { data: rules, mutate: revalidate } =
|
||||||
|
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
|
||||||
const initialLoad = useRef(false);
|
const initialLoad = useRef(false);
|
||||||
const { addToast } = useToasts();
|
const { addToast } = useToasts();
|
||||||
const [isValidated, setIsValidated] = useState(sonarr ? true : false);
|
const [isValidated, setIsValidated] = useState(sonarr ? true : false);
|
||||||
const [isTesting, setIsTesting] = useState(false);
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
const [testResponse, setTestResponse] = useState<TestResponse>({
|
const [testResponse, setTestResponse] = useState<SonarrTestResponse>({
|
||||||
profiles: [],
|
profiles: [],
|
||||||
rootFolders: [],
|
rootFolders: [],
|
||||||
languageProfiles: null,
|
languageProfiles: null,
|
||||||
tags: [],
|
tags: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const SonarrSettingsSchema = Yup.object().shape({
|
const SonarrSettingsSchema = Yup.object().shape({
|
||||||
name: Yup.string().required(
|
name: Yup.string().required(
|
||||||
intl.formatMessage(messages.validationNameRequired)
|
intl.formatMessage(messages.validationNameRequired)
|
||||||
@@ -145,7 +160,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
)
|
)
|
||||||
: Yup.number(),
|
: Yup.number(),
|
||||||
externalUrl: Yup.string()
|
externalUrl: Yup.string()
|
||||||
.url(intl.formatMessage(messages.validationApplicationUrl))
|
.matches(
|
||||||
|
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}(\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*))?$/i,
|
||||||
|
intl.formatMessage(messages.validationApplicationUrl)
|
||||||
|
)
|
||||||
.test(
|
.test(
|
||||||
'no-trailing-slash',
|
'no-trailing-slash',
|
||||||
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
|
||||||
@@ -194,7 +212,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) throw new Error();
|
||||||
const data: TestResponse = await res.json();
|
const data: SonarrTestResponse = await res.json();
|
||||||
|
|
||||||
setIsValidated(true);
|
setIsValidated(true);
|
||||||
setTestResponse(data);
|
setTestResponse(data);
|
||||||
@@ -232,6 +250,10 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
}
|
}
|
||||||
}, [sonarr, testConnection]);
|
}, [sonarr, testConnection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
revalidate();
|
||||||
|
}, [overrideRuleModal, revalidate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Transition
|
<Transition
|
||||||
as="div"
|
as="div"
|
||||||
@@ -399,6 +421,7 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
values.is4k ? messages.edit4ksonarr : messages.editsonarr
|
values.is4k ? messages.edit4ksonarr : messages.editsonarr
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
backgroundClickable={!overrideRuleModal.open}
|
||||||
>
|
>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
@@ -1053,6 +1076,38 @@ const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<h3 className="mb-4 text-xl font-bold leading-8 text-gray-100">
|
||||||
|
{intl.formatMessage(messages.overrideRules)}
|
||||||
|
</h3>
|
||||||
|
<ul className="grid grid-cols-2 gap-6">
|
||||||
|
{rules && (
|
||||||
|
<OverrideRuleTile
|
||||||
|
rules={rules}
|
||||||
|
setOverrideRuleModal={setOverrideRuleModal}
|
||||||
|
testResponse={testResponse}
|
||||||
|
sonarr={sonarr}
|
||||||
|
revalidate={revalidate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<li className="min-h-[8rem] rounded-lg border-2 border-dashed border-gray-400 shadow sm:min-h-[11rem]">
|
||||||
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
|
<Button
|
||||||
|
buttonType="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
setOverrideRuleModal({
|
||||||
|
open: true,
|
||||||
|
rule: null,
|
||||||
|
testResponse,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!isValidated}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
<span>{intl.formatMessage(messages.addrule)}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ const messages = defineMessages(
|
|||||||
toastSettingsSuccess: 'Settings saved successfully!',
|
toastSettingsSuccess: 'Settings saved successfully!',
|
||||||
toastSettingsFailure: 'Something went wrong while saving settings.',
|
toastSettingsFailure: 'Something went wrong while saving settings.',
|
||||||
toastSettingsFailureEmail: 'This email is already taken!',
|
toastSettingsFailureEmail: 'This email is already taken!',
|
||||||
|
toastSettingsFailureEmailEmpty:
|
||||||
|
'Another user already has this username. You must set an email',
|
||||||
region: 'Discover Region',
|
region: 'Discover Region',
|
||||||
regionTip: 'Filter content by regional availability',
|
regionTip: 'Filter content by regional availability',
|
||||||
originallanguage: 'Discover Language',
|
originallanguage: 'Discover Language',
|
||||||
@@ -138,7 +140,7 @@ const UserGeneralSettings = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Formik
|
<Formik
|
||||||
initialValues={{
|
initialValues={{
|
||||||
displayName: data?.username ?? '',
|
displayName: data?.username !== user?.email ? data?.username : '',
|
||||||
email: data?.email?.includes('@') ? data.email : '',
|
email: data?.email?.includes('@') ? data.email : '',
|
||||||
discordId: data?.discordId ?? '',
|
discordId: data?.discordId ?? '',
|
||||||
locale: data?.locale,
|
locale: data?.locale,
|
||||||
@@ -203,10 +205,23 @@ const UserGeneralSettings = () => {
|
|||||||
/* empty */
|
/* empty */
|
||||||
}
|
}
|
||||||
if (errorData?.message === ApiErrorCode.InvalidEmail) {
|
if (errorData?.message === ApiErrorCode.InvalidEmail) {
|
||||||
addToast(intl.formatMessage(messages.toastSettingsFailureEmail), {
|
if (values.email) {
|
||||||
autoDismiss: true,
|
addToast(
|
||||||
appearance: 'error',
|
intl.formatMessage(messages.toastSettingsFailureEmail),
|
||||||
});
|
{
|
||||||
|
autoDismiss: true,
|
||||||
|
appearance: 'error',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
addToast(
|
||||||
|
intl.formatMessage(messages.toastSettingsFailureEmailEmpty),
|
||||||
|
{
|
||||||
|
autoDismiss: true,
|
||||||
|
appearance: 'error',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
addToast(intl.formatMessage(messages.toastSettingsFailure), {
|
addToast(intl.formatMessage(messages.toastSettingsFailure), {
|
||||||
autoDismiss: true,
|
autoDismiss: true,
|
||||||
@@ -284,9 +299,9 @@ const UserGeneralSettings = () => {
|
|||||||
name="displayName"
|
name="displayName"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={
|
placeholder={
|
||||||
user?.username ||
|
|
||||||
user?.jellyfinUsername ||
|
user?.jellyfinUsername ||
|
||||||
user?.plexUsername
|
user?.plexUsername ||
|
||||||
|
user?.email
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,15 +25,14 @@ async function extractMessages(
|
|||||||
try {
|
try {
|
||||||
const formattedMessages = messages
|
const formattedMessages = messages
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/^\s*(['"])?([a-zA-Z0-9_-]+)(['"])?:/gm, '"$2":')
|
.replace(/^\s*(['"])?([a-zA-Z0-9_-]+)(['"])?:[\s\n]*/gm, '"$2":')
|
||||||
.replace(
|
.replace(/^"[a-zA-Z0-9_-]+":'.*',?$/gm, (match) => {
|
||||||
/'.*'/g,
|
const parts = /^("[a-zA-Z0-9_-]+":)'(.*)',?$/.exec(match);
|
||||||
(match) =>
|
if (!parts) return match;
|
||||||
`"${match
|
return `${parts[1]}"${parts[2]
|
||||||
.match(/'(.*)'/)?.[1]
|
.replace(/\\/g, '\\\\')
|
||||||
.replace(/\\/g, '\\\\')
|
.replace(/"/g, '\\"')}",`;
|
||||||
.replace(/"/g, '\\"')}"`
|
})
|
||||||
)
|
|
||||||
.replace(/,$/, '');
|
.replace(/,$/, '');
|
||||||
const messagesJson = JSON.parse(`{${formattedMessages}}`);
|
const messagesJson = JSON.parse(`{${formattedMessages}}`);
|
||||||
return { namespace: namespace.trim(), messages: messagesJson };
|
return { namespace: namespace.trim(), messages: messagesJson };
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
"components.Discover.StudioSlider.studios": "Studios",
|
"components.Discover.StudioSlider.studios": "Studios",
|
||||||
"components.Discover.TvGenreList.seriesgenres": "Series Genres",
|
"components.Discover.TvGenreList.seriesgenres": "Series Genres",
|
||||||
"components.Discover.TvGenreSlider.tvgenres": "Series Genres",
|
"components.Discover.TvGenreSlider.tvgenres": "Series Genres",
|
||||||
|
"components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series",
|
||||||
"components.Discover.createnewslider": "Create New Slider",
|
"components.Discover.createnewslider": "Create New Slider",
|
||||||
"components.Discover.customizediscover": "Customize Discover",
|
"components.Discover.customizediscover": "Customize Discover",
|
||||||
"components.Discover.discover": "Discover",
|
"components.Discover.discover": "Discover",
|
||||||
@@ -298,7 +299,6 @@
|
|||||||
"components.ManageSlideOver.plays": "<strong>{playCount, number}</strong> {playCount, plural, one {play} other {plays}}",
|
"components.ManageSlideOver.plays": "<strong>{playCount, number}</strong> {playCount, plural, one {play} other {plays}}",
|
||||||
"components.ManageSlideOver.removearr": "Remove from {arr}",
|
"components.ManageSlideOver.removearr": "Remove from {arr}",
|
||||||
"components.ManageSlideOver.removearr4k": "Remove from 4K {arr}",
|
"components.ManageSlideOver.removearr4k": "Remove from 4K {arr}",
|
||||||
"components.RequestList.RequestItem.removearr": "Remove from {arr}",
|
|
||||||
"components.ManageSlideOver.tvshow": "series",
|
"components.ManageSlideOver.tvshow": "series",
|
||||||
"components.MediaSlider.ShowMoreCard.seemore": "See More",
|
"components.MediaSlider.ShowMoreCard.seemore": "See More",
|
||||||
"components.MovieDetails.MovieCast.fullcast": "Full Cast",
|
"components.MovieDetails.MovieCast.fullcast": "Full Cast",
|
||||||
@@ -494,6 +494,7 @@
|
|||||||
"components.RequestList.RequestItem.modified": "Modified",
|
"components.RequestList.RequestItem.modified": "Modified",
|
||||||
"components.RequestList.RequestItem.modifieduserdate": "{date} by {user}",
|
"components.RequestList.RequestItem.modifieduserdate": "{date} by {user}",
|
||||||
"components.RequestList.RequestItem.profileName": "Profile",
|
"components.RequestList.RequestItem.profileName": "Profile",
|
||||||
|
"components.RequestList.RequestItem.removearr": "Remove from {arr}",
|
||||||
"components.RequestList.RequestItem.requested": "Requested",
|
"components.RequestList.RequestItem.requested": "Requested",
|
||||||
"components.RequestList.RequestItem.requesteddate": "Requested",
|
"components.RequestList.RequestItem.requesteddate": "Requested",
|
||||||
"components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",
|
"components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",
|
||||||
@@ -589,6 +590,7 @@
|
|||||||
"components.Selector.searchKeywords": "Search keywords…",
|
"components.Selector.searchKeywords": "Search keywords…",
|
||||||
"components.Selector.searchStatus": "Select status...",
|
"components.Selector.searchStatus": "Select status...",
|
||||||
"components.Selector.searchStudios": "Search studios…",
|
"components.Selector.searchStudios": "Search studios…",
|
||||||
|
"components.Selector.searchUsers": "Select users…",
|
||||||
"components.Selector.showless": "Show Less",
|
"components.Selector.showless": "Show Less",
|
||||||
"components.Selector.showmore": "Show More",
|
"components.Selector.showmore": "Show More",
|
||||||
"components.Selector.starttyping": "Starting typing to search.",
|
"components.Selector.starttyping": "Starting typing to search.",
|
||||||
@@ -727,37 +729,63 @@
|
|||||||
"components.Settings.Notifications.validationSmtpPortRequired": "You must provide a valid port number",
|
"components.Settings.Notifications.validationSmtpPortRequired": "You must provide a valid port number",
|
||||||
"components.Settings.Notifications.validationTypes": "You must select at least one notification type",
|
"components.Settings.Notifications.validationTypes": "You must select at least one notification type",
|
||||||
"components.Settings.Notifications.validationUrl": "You must provide a valid URL",
|
"components.Settings.Notifications.validationUrl": "You must provide a valid URL",
|
||||||
|
"components.Settings.Notifications.validationWebhookRoleId": "You must provide a valid Discord Role ID",
|
||||||
|
"components.Settings.Notifications.webhookRoleId": "Notification Role ID",
|
||||||
|
"components.Settings.Notifications.webhookRoleIdTip": "The role ID to mention in the webhook message. Leave empty to disable mentions",
|
||||||
"components.Settings.Notifications.webhookUrl": "Webhook URL",
|
"components.Settings.Notifications.webhookUrl": "Webhook URL",
|
||||||
"components.Settings.Notifications.webhookUrlTip": "Create a <DiscordWebhookLink>webhook integration</DiscordWebhookLink> in your server",
|
"components.Settings.Notifications.webhookUrlTip": "Create a <DiscordWebhookLink>webhook integration</DiscordWebhookLink> in your server",
|
||||||
|
"components.Settings.OverrideRuleTile.conditions": "Conditions",
|
||||||
|
"components.Settings.OverrideRuleTile.genre": "Genre",
|
||||||
|
"components.Settings.OverrideRuleTile.keywords": "Keywords",
|
||||||
|
"components.Settings.OverrideRuleTile.language": "Language",
|
||||||
|
"components.Settings.OverrideRuleTile.qualityprofile": "Quality Profile",
|
||||||
|
"components.Settings.OverrideRuleTile.rootfolder": "Root Folder",
|
||||||
|
"components.Settings.OverrideRuleTile.settings": "Settings",
|
||||||
|
"components.Settings.OverrideRuleTile.tags": "Tags",
|
||||||
|
"components.Settings.OverrideRuleTile.users": "Users",
|
||||||
"components.Settings.RadarrModal.add": "Add Server",
|
"components.Settings.RadarrModal.add": "Add Server",
|
||||||
|
"components.Settings.RadarrModal.addrule": "New Override Rule",
|
||||||
"components.Settings.RadarrModal.announced": "Announced",
|
"components.Settings.RadarrModal.announced": "Announced",
|
||||||
"components.Settings.RadarrModal.apiKey": "API Key",
|
"components.Settings.RadarrModal.apiKey": "API Key",
|
||||||
"components.Settings.RadarrModal.baseUrl": "URL Base",
|
"components.Settings.RadarrModal.baseUrl": "URL Base",
|
||||||
|
"components.Settings.RadarrModal.conditions": "Conditions",
|
||||||
|
"components.Settings.RadarrModal.conditionsDescription": "Specifies conditions before applying parameter changes. Each field must be validated for the rules to be applied (AND operation). A field is considered verified if any of its properties match (OR operation).",
|
||||||
|
"components.Settings.RadarrModal.create": "Create rule",
|
||||||
"components.Settings.RadarrModal.create4kradarr": "Add New 4K Radarr Server",
|
"components.Settings.RadarrModal.create4kradarr": "Add New 4K Radarr Server",
|
||||||
"components.Settings.RadarrModal.createradarr": "Add New Radarr Server",
|
"components.Settings.RadarrModal.createradarr": "Add New Radarr Server",
|
||||||
|
"components.Settings.RadarrModal.createrule": "New Override Rule",
|
||||||
"components.Settings.RadarrModal.default4kserver": "Default 4K Server",
|
"components.Settings.RadarrModal.default4kserver": "Default 4K Server",
|
||||||
"components.Settings.RadarrModal.defaultserver": "Default Server",
|
"components.Settings.RadarrModal.defaultserver": "Default Server",
|
||||||
"components.Settings.RadarrModal.edit4kradarr": "Edit 4K Radarr Server",
|
"components.Settings.RadarrModal.edit4kradarr": "Edit 4K Radarr Server",
|
||||||
"components.Settings.RadarrModal.editradarr": "Edit Radarr Server",
|
"components.Settings.RadarrModal.editradarr": "Edit Radarr Server",
|
||||||
|
"components.Settings.RadarrModal.editrule": "Edit Override Rule",
|
||||||
"components.Settings.RadarrModal.enableSearch": "Enable Automatic Search",
|
"components.Settings.RadarrModal.enableSearch": "Enable Automatic Search",
|
||||||
"components.Settings.RadarrModal.externalUrl": "External URL",
|
"components.Settings.RadarrModal.externalUrl": "External URL",
|
||||||
|
"components.Settings.RadarrModal.genres": "Genres",
|
||||||
"components.Settings.RadarrModal.hostname": "Hostname or IP Address",
|
"components.Settings.RadarrModal.hostname": "Hostname or IP Address",
|
||||||
"components.Settings.RadarrModal.inCinemas": "In Cinemas",
|
"components.Settings.RadarrModal.inCinemas": "In Cinemas",
|
||||||
|
"components.Settings.RadarrModal.keywords": "Keywords",
|
||||||
|
"components.Settings.RadarrModal.languages": "Languages",
|
||||||
"components.Settings.RadarrModal.loadingTags": "Loading tags…",
|
"components.Settings.RadarrModal.loadingTags": "Loading tags…",
|
||||||
"components.Settings.RadarrModal.loadingprofiles": "Loading quality profiles…",
|
"components.Settings.RadarrModal.loadingprofiles": "Loading quality profiles…",
|
||||||
"components.Settings.RadarrModal.loadingrootfolders": "Loading root folders…",
|
"components.Settings.RadarrModal.loadingrootfolders": "Loading root folders…",
|
||||||
"components.Settings.RadarrModal.minimumAvailability": "Minimum Availability",
|
"components.Settings.RadarrModal.minimumAvailability": "Minimum Availability",
|
||||||
"components.Settings.RadarrModal.notagoptions": "No tags.",
|
"components.Settings.RadarrModal.notagoptions": "No tags.",
|
||||||
|
"components.Settings.RadarrModal.overrideRules": "Override Rules",
|
||||||
"components.Settings.RadarrModal.port": "Port",
|
"components.Settings.RadarrModal.port": "Port",
|
||||||
"components.Settings.RadarrModal.qualityprofile": "Quality Profile",
|
"components.Settings.RadarrModal.qualityprofile": "Quality Profile",
|
||||||
"components.Settings.RadarrModal.released": "Released",
|
"components.Settings.RadarrModal.released": "Released",
|
||||||
"components.Settings.RadarrModal.rootfolder": "Root Folder",
|
"components.Settings.RadarrModal.rootfolder": "Root Folder",
|
||||||
|
"components.Settings.RadarrModal.ruleCreated": "Override rule created successfully!",
|
||||||
|
"components.Settings.RadarrModal.ruleUpdated": "Override rule updated successfully!",
|
||||||
"components.Settings.RadarrModal.selectMinimumAvailability": "Select minimum availability",
|
"components.Settings.RadarrModal.selectMinimumAvailability": "Select minimum availability",
|
||||||
"components.Settings.RadarrModal.selectQualityProfile": "Select quality profile",
|
"components.Settings.RadarrModal.selectQualityProfile": "Select quality profile",
|
||||||
"components.Settings.RadarrModal.selectRootFolder": "Select root folder",
|
"components.Settings.RadarrModal.selectRootFolder": "Select root folder",
|
||||||
"components.Settings.RadarrModal.selecttags": "Select tags",
|
"components.Settings.RadarrModal.selecttags": "Select tags",
|
||||||
"components.Settings.RadarrModal.server4k": "4K Server",
|
"components.Settings.RadarrModal.server4k": "4K Server",
|
||||||
"components.Settings.RadarrModal.servername": "Server Name",
|
"components.Settings.RadarrModal.servername": "Server Name",
|
||||||
|
"components.Settings.RadarrModal.settings": "Settings",
|
||||||
|
"components.Settings.RadarrModal.settingsDescription": "Specifies which settings will be changed when the above conditions are met.",
|
||||||
"components.Settings.RadarrModal.ssl": "Use SSL",
|
"components.Settings.RadarrModal.ssl": "Use SSL",
|
||||||
"components.Settings.RadarrModal.syncEnabled": "Enable Scan",
|
"components.Settings.RadarrModal.syncEnabled": "Enable Scan",
|
||||||
"components.Settings.RadarrModal.tagRequests": "Tag Requests",
|
"components.Settings.RadarrModal.tagRequests": "Tag Requests",
|
||||||
@@ -768,6 +796,7 @@
|
|||||||
"components.Settings.RadarrModal.testFirstTags": "Test connection to load tags",
|
"components.Settings.RadarrModal.testFirstTags": "Test connection to load tags",
|
||||||
"components.Settings.RadarrModal.toastRadarrTestFailure": "Failed to connect to Radarr.",
|
"components.Settings.RadarrModal.toastRadarrTestFailure": "Failed to connect to Radarr.",
|
||||||
"components.Settings.RadarrModal.toastRadarrTestSuccess": "Radarr connection established successfully!",
|
"components.Settings.RadarrModal.toastRadarrTestSuccess": "Radarr connection established successfully!",
|
||||||
|
"components.Settings.RadarrModal.users": "Users",
|
||||||
"components.Settings.RadarrModal.validationApiKeyRequired": "You must provide an API key",
|
"components.Settings.RadarrModal.validationApiKeyRequired": "You must provide an API key",
|
||||||
"components.Settings.RadarrModal.validationApplicationUrl": "You must provide a valid URL",
|
"components.Settings.RadarrModal.validationApplicationUrl": "You must provide a valid URL",
|
||||||
"components.Settings.RadarrModal.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
|
"components.Settings.RadarrModal.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
|
||||||
@@ -885,6 +914,15 @@
|
|||||||
"components.Settings.SettingsMain.originallanguage": "Discover Language",
|
"components.Settings.SettingsMain.originallanguage": "Discover Language",
|
||||||
"components.Settings.SettingsMain.originallanguageTip": "Filter content by original language",
|
"components.Settings.SettingsMain.originallanguageTip": "Filter content by original language",
|
||||||
"components.Settings.SettingsMain.partialRequestsEnabled": "Allow Partial Series Requests",
|
"components.Settings.SettingsMain.partialRequestsEnabled": "Allow Partial Series Requests",
|
||||||
|
"components.Settings.SettingsMain.proxyBypassFilter": "Proxy Ignored Addresses",
|
||||||
|
"components.Settings.SettingsMain.proxyBypassFilterTip": "Use ',' as a separator, and '*.' as a wildcard for subdomains",
|
||||||
|
"components.Settings.SettingsMain.proxyBypassLocalAddresses": "Bypass Proxy for Local Addresses",
|
||||||
|
"components.Settings.SettingsMain.proxyEnabled": "HTTP(S) Proxy",
|
||||||
|
"components.Settings.SettingsMain.proxyHostname": "Proxy Hostname",
|
||||||
|
"components.Settings.SettingsMain.proxyPassword": "Proxy Password",
|
||||||
|
"components.Settings.SettingsMain.proxyPort": "Proxy Port",
|
||||||
|
"components.Settings.SettingsMain.proxySsl": "Use SSL For Proxy",
|
||||||
|
"components.Settings.SettingsMain.proxyUser": "Proxy Username",
|
||||||
"components.Settings.SettingsMain.region": "Discover Region",
|
"components.Settings.SettingsMain.region": "Discover Region",
|
||||||
"components.Settings.SettingsMain.regionTip": "Filter content by regional availability",
|
"components.Settings.SettingsMain.regionTip": "Filter content by regional availability",
|
||||||
"components.Settings.SettingsMain.toastApiKeyFailure": "Something went wrong while generating a new API key.",
|
"components.Settings.SettingsMain.toastApiKeyFailure": "Something went wrong while generating a new API key.",
|
||||||
@@ -896,6 +934,7 @@
|
|||||||
"components.Settings.SettingsMain.validationApplicationTitle": "You must provide an application title",
|
"components.Settings.SettingsMain.validationApplicationTitle": "You must provide an application title",
|
||||||
"components.Settings.SettingsMain.validationApplicationUrl": "You must provide a valid URL",
|
"components.Settings.SettingsMain.validationApplicationUrl": "You must provide a valid URL",
|
||||||
"components.Settings.SettingsMain.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
|
"components.Settings.SettingsMain.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
|
||||||
|
"components.Settings.SettingsMain.validationProxyPort": "You must provide a valid port",
|
||||||
"components.Settings.SettingsUsers.defaultPermissions": "Default Permissions",
|
"components.Settings.SettingsUsers.defaultPermissions": "Default Permissions",
|
||||||
"components.Settings.SettingsUsers.defaultPermissionsTip": "Initial permissions assigned to new users",
|
"components.Settings.SettingsUsers.defaultPermissionsTip": "Initial permissions assigned to new users",
|
||||||
"components.Settings.SettingsUsers.localLogin": "Enable Local Sign-In",
|
"components.Settings.SettingsUsers.localLogin": "Enable Local Sign-In",
|
||||||
@@ -910,6 +949,7 @@
|
|||||||
"components.Settings.SettingsUsers.userSettingsDescription": "Configure global and default user settings.",
|
"components.Settings.SettingsUsers.userSettingsDescription": "Configure global and default user settings.",
|
||||||
"components.Settings.SettingsUsers.users": "Users",
|
"components.Settings.SettingsUsers.users": "Users",
|
||||||
"components.Settings.SonarrModal.add": "Add Server",
|
"components.Settings.SonarrModal.add": "Add Server",
|
||||||
|
"components.Settings.SonarrModal.addrule": "New Override Rule",
|
||||||
"components.Settings.SonarrModal.animeSeriesType": "Anime Series Type",
|
"components.Settings.SonarrModal.animeSeriesType": "Anime Series Type",
|
||||||
"components.Settings.SonarrModal.animeTags": "Anime Tags",
|
"components.Settings.SonarrModal.animeTags": "Anime Tags",
|
||||||
"components.Settings.SonarrModal.animelanguageprofile": "Anime Language Profile",
|
"components.Settings.SonarrModal.animelanguageprofile": "Anime Language Profile",
|
||||||
@@ -932,6 +972,7 @@
|
|||||||
"components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…",
|
"components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…",
|
||||||
"components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…",
|
"components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…",
|
||||||
"components.Settings.SonarrModal.notagoptions": "No tags.",
|
"components.Settings.SonarrModal.notagoptions": "No tags.",
|
||||||
|
"components.Settings.SonarrModal.overrideRules": "Override Rules",
|
||||||
"components.Settings.SonarrModal.port": "Port",
|
"components.Settings.SonarrModal.port": "Port",
|
||||||
"components.Settings.SonarrModal.qualityprofile": "Quality Profile",
|
"components.Settings.SonarrModal.qualityprofile": "Quality Profile",
|
||||||
"components.Settings.SonarrModal.rootfolder": "Root Folder",
|
"components.Settings.SonarrModal.rootfolder": "Root Folder",
|
||||||
@@ -1084,7 +1125,7 @@
|
|||||||
"components.Setup.finishing": "Finishing…",
|
"components.Setup.finishing": "Finishing…",
|
||||||
"components.Setup.servertype": "Choose Server Type",
|
"components.Setup.servertype": "Choose Server Type",
|
||||||
"components.Setup.setup": "Setup",
|
"components.Setup.setup": "Setup",
|
||||||
"components.Setup.signin": "Sign in to your account",
|
"components.Setup.signin": "Sign In",
|
||||||
"components.Setup.signinMessage": "Get started by signing in",
|
"components.Setup.signinMessage": "Get started by signing in",
|
||||||
"components.Setup.signinWithEmby": "Enter your Emby details",
|
"components.Setup.signinWithEmby": "Enter your Emby details",
|
||||||
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details",
|
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details",
|
||||||
@@ -1235,6 +1276,7 @@
|
|||||||
"components.UserProfile.UserSettings.UserGeneralSettings.seriesrequestlimit": "Series Request Limit",
|
"components.UserProfile.UserSettings.UserGeneralSettings.seriesrequestlimit": "Series Request Limit",
|
||||||
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailure": "Something went wrong while saving settings.",
|
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailure": "Something went wrong while saving settings.",
|
||||||
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailureEmail": "This email is already taken!",
|
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailureEmail": "This email is already taken!",
|
||||||
|
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailureEmailEmpty": "Another user already has this username. You must set an email",
|
||||||
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!",
|
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!",
|
||||||
"components.UserProfile.UserSettings.UserGeneralSettings.user": "User",
|
"components.UserProfile.UserSettings.UserGeneralSettings.user": "User",
|
||||||
"components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID",
|
"components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID",
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
import { defineMessages as intlDefineMessages } from 'react-intl';
|
import { defineMessages as intlDefineMessages } from 'react-intl';
|
||||||
|
|
||||||
export default function defineMessages(
|
type Messages<T extends Record<string, string>> = {
|
||||||
|
[K in keyof T]: {
|
||||||
|
id: string;
|
||||||
|
defaultMessage: T[K];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function defineMessages<T extends Record<string, string>>(
|
||||||
prefix: string,
|
prefix: string,
|
||||||
messages: Record<string, string>
|
messages: T
|
||||||
) {
|
): Messages<T> {
|
||||||
const modifiedMessages: Record<
|
const keys: (keyof T)[] = Object.keys(messages);
|
||||||
string,
|
const modifiedMessagesEntries = keys.map((key) => [
|
||||||
{ id: string; defaultMessage: string }
|
key,
|
||||||
> = {};
|
{
|
||||||
for (const key of Object.keys(messages)) {
|
id: `${prefix}.${key as string}`,
|
||||||
modifiedMessages[key] = {
|
|
||||||
id: prefix + '.' + key,
|
|
||||||
defaultMessage: messages[key],
|
defaultMessage: messages[key],
|
||||||
};
|
},
|
||||||
}
|
]);
|
||||||
|
const modifiedMessages: Messages<T> = Object.fromEntries(
|
||||||
|
modifiedMessagesEntries
|
||||||
|
);
|
||||||
return intlDefineMessages(modifiedMessages);
|
return intlDefineMessages(modifiedMessages);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user