Compare commits

..

20 Commits

Author SHA1 Message Date
THOMAS B
2ee5535819 Merge branch 'develop' into feat-add-tvdb-indexer 2024-11-03 13:15:11 +01:00
TOomaAh
67a846cd58 test(tvdb): change 'use' to 'tvdb' condition check 2024-10-29 23:28:28 +01:00
TOomaAh
a5e8320e8a refactor(tmdb): reduce still path condition 2024-10-29 23:20:41 +01:00
TOomaAh
3f16176667 refactor(settings): replace tvdb object to boolean type 2024-10-29 23:10:35 +01:00
TOomaAh
85aeeb084e refactor(indexer): remove unused getSeasonIdentifier method 2024-10-28 18:37:52 +01:00
TOomaAh
b865d65fad style(tvdb): rename pokemon to correct tv show 2024-10-27 13:06:27 +01:00
TOomaAh
47eece9c44 test(tvdb): add tvdb tests 2024-10-27 11:23:05 +01:00
TOomaAh
72277ea983 fix(test): wrong url tv-details 2024-10-27 01:48:07 +02:00
TOomaAh
57e2f7b374 fix(test): fix discover test 2024-10-27 01:18:27 +02:00
TOomaAh
6f8d4bf00a style: tvdb.login to tvdb.test 2024-10-26 16:13:52 +02:00
TOomaAh
61ecf74b28 style: replace avalaible to available 2024-10-26 15:52:40 +02:00
TOomaAh
976781d470 fix: wrong language with tmdb indexer 2024-10-23 12:36:37 +02:00
TOomaAh
422012f7b5 refactor: clean tvdb indexer code 2024-10-22 22:57:54 +02:00
TOomaAh
32f500a4e7 fix: error if tmdb poster is null 2024-10-20 16:27:08 +02:00
TOomaAh
7b07004c5b fix: error during get episodes 2024-10-20 16:17:42 +02:00
TOomaAh
87253e8bb7 refactor(tvdb): replace tvdb api by skyhook 2024-10-20 01:46:08 +02:00
TOomaAh
7bcda9521e Merge branch 'develop' into feat-add-tvdb-indexer
# Conflicts:
#	server/routes/tv.ts
#	src/components/TvDetails/index.tsx
2024-10-19 23:49:26 +02:00
TOomaAh
2d51b16694 fix(usersettings): remove unused column tvdbtoken 2024-07-27 01:28:28 +02:00
TOomaAh
2a0bcdf41c fix: fix rate limiter index tvdb indexer 2024-07-27 00:11:22 +02:00
TOomaAh
79e542ef12 feat(tvdb): get tv seasons/episodes with tvdb 2024-07-26 23:56:22 +02:00
92 changed files with 1155 additions and 1954 deletions

View File

@@ -18,7 +18,7 @@ config/logs/*
config/*.json
dist
Dockerfile*
compose.yaml
docker-compose.yml
docs
LICENSE
node_modules

2
.gitattributes vendored
View File

@@ -40,7 +40,7 @@ docs export-ignore
.all-contributorsrc export-ignore
.editorconfig export-ignore
Dockerfile.local export-ignore
compose.yaml export-ignore
docker-compose.yml export-ignore
stylelint.config.js export-ignore
public/os_logo_filled.png export-ignore

View File

@@ -48,11 +48,11 @@ All help is welcome and greatly appreciated! If you would like to contribute to
4. Run the development environment:
```bash
pnpm install
pnpm
pnpm dev
```
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
- Alternatively, you can use [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
5. Create your patch and test your changes.

View File

@@ -75,7 +75,6 @@
"types": 0,
"options": {
"webhookUrl": "",
"webhookRoleId": "",
"enableMentions": true
}
},

View File

@@ -0,0 +1,92 @@
describe('TVDB Integration', () => {
// Constants for routes and selectors
const ROUTES = {
home: '/',
tvdbSettings: '/settings/tvdb',
tomorrowIsOursTvShow: '/tv/72879',
monsterTvShow: '/tv/225634',
};
const SELECTORS = {
sidebarToggle: '[data-testid=sidebar-toggle]',
sidebarSettingsMobile: '[data-testid=sidebar-menu-settings-mobile]',
settingsNavDesktop: 'nav[data-testid="settings-nav-desktop"]',
tvdbEnable: 'input[data-testid="tvdb-enable"]',
tvdbSaveButton: '[data-testid=tvbd-save-button]',
heading: '.heading',
season1: 'Season 1',
season2: 'Season 2',
};
// Reusable commands
const toggleTVDBSetting = () => {
cy.intercept('/api/v1/settings/tvdb').as('tvdbRequest');
cy.get(SELECTORS.tvdbSaveButton).click();
return cy.wait('@tvdbRequest');
};
const verifyTVDBResponse = (response, expectedUseValue) => {
expect(response.statusCode).to.equal(200);
expect(response.body.tvdb).to.equal(expectedUseValue);
};
beforeEach(() => {
// Perform login
cy.login(Cypress.env('ADMIN_EMAIL'), Cypress.env('ADMIN_PASSWORD'));
// Navigate to TVDB settings
cy.visit(ROUTES.home);
cy.get(SELECTORS.sidebarToggle).click();
cy.get(SELECTORS.sidebarSettingsMobile).click();
cy.get(
`${SELECTORS.settingsNavDesktop} a[href="${ROUTES.tvdbSettings}"]`
).click();
// Verify heading
cy.get(SELECTORS.heading).should('contain', 'Tvdb');
// Configure TVDB settings
cy.get(SELECTORS.tvdbEnable).then(($checkbox) => {
const isChecked = $checkbox.is(':checked');
if (!isChecked) {
// If disabled, enable TVDB
cy.wrap($checkbox).click();
toggleTVDBSetting().then(({ response }) => {
verifyTVDBResponse(response, true);
});
} else {
// If enabled, disable then re-enable TVDB
cy.wrap($checkbox).click();
toggleTVDBSetting().then(({ response }) => {
verifyTVDBResponse(response, false);
});
cy.wrap($checkbox).click();
toggleTVDBSetting().then(({ response }) => {
verifyTVDBResponse(response, true);
});
}
});
});
it('should display "Tomorrow is Ours" show information correctly (1 season on TMDB >1 seasons on TVDB)', () => {
cy.visit(ROUTES.tomorrowIsOursTvShow);
cy.contains(SELECTORS.season2)
.should('be.visible')
.scrollIntoView()
.click();
});
it('Should display "Monster" show information correctly (Not existing on TVDB)', () => {
cy.visit(ROUTES.monsterTvShow);
cy.intercept('/api/v1/tv/225634/season/1').as('season1');
cy.contains(SELECTORS.season1)
.should('be.visible')
.scrollIntoView()
.click();
cy.wait('@season1');
cy.contains('9 - Hang Men').should('be.visible');
});
});

View File

@@ -1,3 +1,4 @@
version: '3'
services:
jellyseerr:
build:

View File

@@ -95,8 +95,6 @@ location ^~ /jellyseerr {
sub_filter '/api/v1' '/$app/api/v1';
sub_filter '/login/plex/loading' '/$app/login/plex/loading';
sub_filter '/images/' '/$app/images/';
sub_filter '/imageproxy/' '/$app/imageproxy/';
sub_filter '/avatarproxy/' '/$app/avatarproxy/';
sub_filter '/android-' '/$app/android-';
sub_filter '/apple-' '/$app/apple-';
sub_filter '/favicon' '/$app/favicon';
@@ -192,7 +190,7 @@ Caddy will automatically obtain and renew SSL certificates for your domain.
## Traefik (v2)
Add the following labels to the Jellyseerr service in your `compose.yaml` file:
Add the following labels to the Jellyseerr service in your `docker-compose.yml` file:
```yaml
labels:

View File

@@ -71,7 +71,7 @@ You could also use [diun](https://github.com/crazy-max/diun) to receive notifica
For details on how to use Docker Compose, please [review the official Compose documentation](https://docs.docker.com/compose/reference/).
#### Installation:
Define the `jellyseerr` service in your `compose.yaml` as follows:
Define the `jellyseerr` service in your `docker-compose.yml` as follows:
```yaml
---
services:
@@ -94,17 +94,17 @@ If you are using emby, make sure to set the `JELLYFIN_TYPE` environment variable
Then, start all services defined in the Compose file:
```bash
docker compose up -d
docker-compose up -d
```
#### Updating:
Pull the latest image:
```bash
docker compose pull jellyseerr
docker-compose pull jellyseerr
```
Then, restart all services defined in the Compose file:
```bash
docker compose up -d
docker-compose up -d
```
:::tip
You may alternatively use a third-party mechanism like [dockge](https://github.com/louislam/dockge) to manage your docker compose files.

View File

@@ -18,10 +18,6 @@ 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**.
### 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)
If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like!

View File

@@ -400,6 +400,12 @@ components:
serverID:
type: string
readOnly: true
TvdbSettings:
type: object
properties:
use:
type: boolean
example: true
TautulliSettings:
type: object
properties:
@@ -1273,8 +1279,6 @@ components:
type: string
webhookUrl:
type: string
webhookRoleId:
type: string
enableMentions:
type: boolean
SlackSettings:
@@ -1932,11 +1936,6 @@ components:
type: string
native_name:
type: string
OverrideRule:
type: object
properties:
id:
type: string
securitySchemes:
cookieAuth:
type: apiKey
@@ -2368,6 +2367,60 @@ paths:
type: string
thumb:
type: string
/settings/tvdb:
get:
summary: Get TVDB settings
description: Retrieves current TVDB settings.
tags:
- settings
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/TvdbSettings'
put:
summary: Update TVDB settings
description: Updates TVDB settings with the provided values.
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TvdbSettings'
responses:
'200':
description: 'Values were successfully updated'
content:
application/json:
schema:
$ref: '#/components/schemas/TvdbSettings'
/settings/tvdb/test:
post:
summary: Test TVDB configuration
description: Tests if the TVDB configuration is valid. Returns a list of available languages on success.
tags:
- settings
responses:
'200':
description: Succesfully connected to TVDB
content:
application/json:
schema:
type: object
properties:
languages:
type: array
items:
type: object
properties:
id:
type: number
name:
type: string
/settings/tautulli:
get:
summary: Get Tautulli settings
@@ -3760,11 +3813,6 @@ paths:
type: string
enum: [created, updated, requests, displayname]
default: created
- in: query
name: q
required: false
schema:
type: string
responses:
'200':
description: A JSON array of all users
@@ -4154,21 +4202,6 @@ paths:
'412':
description: Item has already been blacklisted
/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:
summary: Remove media from blacklist
tags:
@@ -5936,7 +5969,7 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/TvDetails'
/tv/{tvId}/season/{seasonId}:
/tv/{tvId}/season/{seasonNumber}:
get:
summary: Get season details and episode list
description: Returns season details with a list of episodes in a JSON object.
@@ -5950,11 +5983,11 @@ paths:
type: number
example: 76479
- in: path
name: seasonId
name: seasonNumber
required: true
schema:
type: number
example: 1
example: 123456
- in: query
name: language
schema:
@@ -6966,68 +6999,6 @@ paths:
type: array
items:
$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:
- cookieAuth: []
- apiKey: []

View File

@@ -8,7 +8,7 @@ const DEFAULT_TTL = 300;
// 10 seconds default rolling buffer (in ms)
const DEFAULT_ROLLING_BUFFER = 10000;
interface ExternalAPIOptions {
export interface ExternalAPIOptions {
nodeCache?: NodeCache;
headers?: Record<string, unknown>;
rateLimit?: RateLimitOptions;
@@ -53,6 +53,7 @@ class ExternalAPI {
this.baseUrl = baseUrl;
this.params = params;
this.cache = options.nodeCache;
}

View File

@@ -0,0 +1,23 @@
import type {
TmdbSeasonWithEpisodes,
TmdbTvDetails,
} from '@server/api/indexer/themoviedb/interfaces';
export interface TvShowIndexer {
getTvShow({
tvId,
language,
}: {
tvId: number;
language?: string;
}): Promise<TmdbTvDetails>;
getTvSeason({
tvId,
seasonNumber,
language,
}: {
tvId: number;
seasonNumber: number;
language?: string;
}): Promise<TmdbSeasonWithEpisodes>;
}

View File

@@ -1,4 +1,5 @@
import ExternalAPI from '@server/api/externalapi';
import type { TvShowIndexer } from '@server/api/indexer';
import cacheManager from '@server/lib/cache';
import { sortBy } from 'lodash';
import type {
@@ -98,7 +99,7 @@ interface DiscoverTvOptions {
withStatus?: string; // Returning Series: 0 Planned: 1 In Production: 2 Ended: 3 Cancelled: 4 Pilot: 5
}
class TheMovieDb extends ExternalAPI {
class TheMovieDb extends ExternalAPI implements TvShowIndexer {
private region?: string;
private originalLanguage?: string;
constructor({
@@ -308,6 +309,12 @@ class TheMovieDb extends ExternalAPI {
}
);
data.episodes = data.episodes.map((episode) => {
if (episode.still_path) {
episode.still_path = `https://image.tmdb.org/t/p/original/${episode.still_path}`;
}
return episode;
});
return data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch TV show details: ${e.message}`);

View File

@@ -220,7 +220,7 @@ export interface TmdbTvEpisodeResult {
show_id: number;
still_path: string;
vote_average: number;
vote_cuont: number;
vote_count: number;
}
export interface TmdbTvSeasonResult {

View File

@@ -0,0 +1,246 @@
import ExternalAPI from '@server/api/externalapi';
import type { TvShowIndexer } from '@server/api/indexer';
import TheMovieDb from '@server/api/indexer/themoviedb';
import type {
TmdbSeasonWithEpisodes,
TmdbTvDetails,
} from '@server/api/indexer/themoviedb/interfaces';
import type {
TvdbEpisode,
TvdbLoginResponse,
TvdbSeason,
TvdbTvShowDetail,
} from '@server/api/indexer/tvdb/interfaces';
import cacheManager, { type AvailableCacheIds } from '@server/lib/cache';
import logger from '@server/logger';
interface TvdbConfig {
baseUrl: string;
maxRequestsPerSecond: number;
cachePrefix: AvailableCacheIds;
}
const DEFAULT_CONFIG: TvdbConfig = {
baseUrl: 'https://skyhook.sonarr.tv/v1/tvdb/shows',
maxRequestsPerSecond: 50,
cachePrefix: 'tvdb' as const,
};
const enum TvdbIdStatus {
INVALID = -1,
}
type TvdbId = number;
type ValidTvdbId = Exclude<TvdbId, TvdbIdStatus.INVALID>;
class Tvdb extends ExternalAPI implements TvShowIndexer {
private readonly tmdb: TheMovieDb;
private static readonly DEFAULT_CACHE_TTL = 43200;
private static readonly DEFAULT_LANGUAGE = 'en';
constructor(config: Partial<TvdbConfig> = {}) {
const finalConfig = { ...DEFAULT_CONFIG, ...config };
super(
finalConfig.baseUrl,
{},
{
nodeCache: cacheManager.getCache(finalConfig.cachePrefix).data,
rateLimit: {
maxRPS: finalConfig.maxRequestsPerSecond,
id: finalConfig.cachePrefix,
},
}
);
this.tmdb = new TheMovieDb();
}
public async test(): Promise<TvdbLoginResponse> {
try {
return await this.get<TvdbLoginResponse>('/en/445009', {});
} catch (error) {
this.handleError('Login failed', error);
throw error;
}
}
public async getTvShow({
tvId,
language = Tvdb.DEFAULT_LANGUAGE,
}: {
tvId: number;
language?: string;
}): Promise<TmdbTvDetails> {
try {
const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language });
const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
if (this.isValidTvdbId(tvdbId)) {
return await this.enrichTmdbShowWithTvdbData(tmdbTvShow, tvdbId);
}
return tmdbTvShow;
} catch (error) {
this.handleError('Failed to fetch TV show details', error);
throw error;
}
}
public async getTvSeason({
tvId,
seasonNumber,
language = Tvdb.DEFAULT_LANGUAGE,
}: {
tvId: number;
seasonNumber: number;
language?: string;
}): Promise<TmdbSeasonWithEpisodes> {
if (seasonNumber === 0) {
return this.createEmptySeasonResponse(tvId);
}
try {
const tmdbTvShow = await this.tmdb.getTvShow({ tvId, language });
const tvdbId = this.getTvdbIdFromTmdb(tmdbTvShow);
if (!this.isValidTvdbId(tvdbId)) {
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
}
return await this.getTvdbSeasonData(tvdbId, seasonNumber, tvId);
} catch (error) {
logger.error(
`[TVDB] Failed to fetch TV season details: ${error.message}`
);
return await this.tmdb.getTvSeason({ tvId, seasonNumber, language });
}
}
private async enrichTmdbShowWithTvdbData(
tmdbTvShow: TmdbTvDetails,
tvdbId: ValidTvdbId
): Promise<TmdbTvDetails> {
try {
const tvdbData = await this.fetchTvdbShowData(tvdbId);
const seasons = this.processSeasons(tvdbData);
return { ...tmdbTvShow, seasons };
} catch (error) {
logger.error(
`Failed to enrich TMDB show with TVDB data: ${error.message}`
);
return tmdbTvShow;
}
}
private async fetchTvdbShowData(tvdbId: number): Promise<TvdbTvShowDetail> {
return await this.get<TvdbTvShowDetail>(
`/en/${tvdbId}`,
{},
Tvdb.DEFAULT_CACHE_TTL
);
}
private processSeasons(tvdbData: TvdbTvShowDetail): any[] {
return tvdbData.seasons
.filter((season) => season.seasonNumber !== 0)
.map((season) => this.createSeasonData(season, tvdbData));
}
private createSeasonData(
season: TvdbSeason,
tvdbData: TvdbTvShowDetail
): any {
if (!season.seasonNumber) return null;
const episodeCount = tvdbData.episodes.filter(
(episode) => episode.seasonNumber === season.seasonNumber
).length;
return {
id: tvdbData.tvdbId,
episode_count: episodeCount,
name: `${season.seasonNumber}`,
overview: '',
season_number: season.seasonNumber,
poster_path: '',
air_date: '',
image: '',
};
}
private async getTvdbSeasonData(
tvdbId: number,
seasonNumber: number,
tvId: number
): Promise<TmdbSeasonWithEpisodes> {
const tvdbSeason = await this.fetchTvdbShowData(tvdbId);
const episodes = this.processEpisodes(tvdbSeason, seasonNumber, tvId);
return {
episodes,
external_ids: { tvdb_id: tvdbSeason.tvdbId },
name: '',
overview: '',
id: tvdbSeason.tvdbId,
air_date: tvdbSeason.firstAired,
season_number: episodes.length,
};
}
private processEpisodes(
tvdbSeason: TvdbTvShowDetail,
seasonNumber: number,
tvId: number
): any[] {
return tvdbSeason.episodes
.filter((episode) => episode.seasonNumber === seasonNumber)
.map((episode, index) => this.createEpisodeData(episode, index, tvId));
}
private createEpisodeData(
episode: TvdbEpisode,
index: number,
tvId: number
): any {
return {
id: episode.tvdbId,
air_date: episode.airDate,
episode_number: episode.episodeNumber,
name: episode.title || `Episode ${index + 1}`,
overview: episode.overview || '',
season_number: episode.seasonNumber,
production_code: '',
show_id: tvId,
still_path: episode.image || '',
vote_average: 1,
vote_count: 1,
};
}
private createEmptySeasonResponse(tvId: number): TmdbSeasonWithEpisodes {
return {
episodes: [],
external_ids: { tvdb_id: tvId },
name: '',
overview: '',
id: 0,
air_date: '',
season_number: 0,
};
}
private getTvdbIdFromTmdb(tmdbTvShow: TmdbTvDetails): TvdbId {
return tmdbTvShow?.external_ids?.tvdb_id ?? TvdbIdStatus.INVALID;
}
private isValidTvdbId(tvdbId: TvdbId): tvdbId is ValidTvdbId {
return tvdbId !== TvdbIdStatus.INVALID;
}
private handleError(context: string, error: Error): void {
throw new Error(`[TVDB] ${context}: ${error.message}`);
}
}
export default Tvdb;

View File

@@ -0,0 +1,80 @@
export interface TvdbBaseResponse<T> {
data: T;
errors: string;
}
export interface TvdbLoginResponse extends TvdbBaseResponse<{ token: string }> {
data: { token: string };
}
export interface TvdbTvShowDetail {
tvdbId: number;
title: string;
overview: string;
slug: string;
originalCountry: string;
originalLanguage: string;
language: string;
firstAired: string;
lastAired: string;
tvMazeId: number;
tmdbId: number;
imdbId: string;
lastUpdated: string;
status: string;
runtime: number;
timeOfDay: TvdbTimeOfDay;
originalNetwork: string;
network: string;
genres: string[];
alternativeTitles: TvdbAlternativeTitle[];
actors: TvdbActor[];
images: TvdbImage[];
seasons: TvdbSeason[];
episodes: TvdbEpisode[];
}
export interface TvdbTimeOfDay {
hours: number;
minutes: number;
}
export interface TvdbAlternativeTitle {
title: string;
}
export interface TvdbActor {
name: string;
character: string;
image?: string;
}
export interface TvdbImage {
coverType: string;
url: string;
}
export interface TvdbSeason {
seasonNumber: number;
}
export interface TvdbEpisode {
tvdbShowId: number;
tvdbId: number;
seasonNumber: number;
episodeNumber: number;
absoluteEpisodeNumber: number;
title?: string;
airDate: string;
airDateUtc: string;
runtime?: number;
overview?: string;
image?: string;
}
export interface TvdbEpisodeTranslation
extends TvdbBaseResponse<TvdbEpisodeTranslation> {
name: string;
overview: string;
language: string;
}

View File

@@ -138,38 +138,39 @@ class JellyfinAPI extends ExternalAPI {
try {
return await authenticate(true);
} catch (e) {
logger.debug('Failed to authenticate with headers', {
logger.debug(`Failed to authenticate with headers: ${e.message}`, {
label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
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 {
return await authenticate(false);
} catch (e) {
if (e.cause.status === 401) {
throw new ApiError(e.cause.status, ApiErrorCode.InvalidCredentials);
const status = e.cause?.status;
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);
}
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);
throw new ApiError(status, ApiErrorCode.InvalidCredentials);
}
}
@@ -197,8 +198,8 @@ class JellyfinAPI extends ExternalAPI {
return serverResponse.ServerName;
} catch (e) {
logger.error(
'Something went wrong while getting the server name from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting the server name from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.cause?.status, ApiErrorCode.Unknown);
@@ -212,8 +213,8 @@ class JellyfinAPI extends ExternalAPI {
return { users: userReponse };
} catch (e) {
logger.error(
'Something went wrong while getting the account from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -228,8 +229,8 @@ class JellyfinAPI extends ExternalAPI {
return userReponse;
} catch (e) {
logger.error(
'Something went wrong while getting the account from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting the account from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -252,11 +253,8 @@ class JellyfinAPI extends ExternalAPI {
return this.mapLibraries(mediaFolderResponse.Items);
} catch (e) {
logger.error(
'Something went wrong while getting libraries from the Jellyfin server',
{
label: 'Jellyfin API',
error: e.cause.message ?? e.cause.statusText,
}
`Something went wrong while getting libraries from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
return [];
@@ -310,8 +308,8 @@ class JellyfinAPI extends ExternalAPI {
);
} catch (e) {
logger.error(
'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -331,8 +329,8 @@ class JellyfinAPI extends ExternalAPI {
return itemResponse;
} catch (e) {
logger.error(
'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -356,8 +354,8 @@ class JellyfinAPI extends ExternalAPI {
}
logger.error(
'Something went wrong while getting library content from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting library content from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
}
@@ -370,8 +368,8 @@ class JellyfinAPI extends ExternalAPI {
return seasonResponse.Items;
} catch (e) {
logger.error(
'Something went wrong while getting the list of seasons from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting the list of seasons from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -395,8 +393,8 @@ class JellyfinAPI extends ExternalAPI {
);
} catch (e) {
logger.error(
'Something went wrong while getting the list of episodes from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while getting the list of episodes from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.cause?.status, ApiErrorCode.InvalidAuthToken);
@@ -412,8 +410,8 @@ class JellyfinAPI extends ExternalAPI {
).AccessToken;
} catch (e) {
logger.error(
'Something went wrong while creating an API key from the Jellyfin server',
{ label: 'Jellyfin API', error: e.cause.message ?? e.cause.statusText }
`Something went wrong while creating an API key from the Jellyfin server: ${e.message}`,
{ label: 'Jellyfin API' }
);
throw new ApiError(e.response?.status, ApiErrorCode.InvalidAuthToken);

View File

@@ -80,12 +80,12 @@ export class Blacklist implements BlacklistItem {
status: MediaStatus.BLACKLISTED,
status4k: MediaStatus.BLACKLISTED,
mediaType: blacklistRequest.mediaType,
blacklist: Promise.resolve(blacklist),
blacklist: blacklist,
});
await mediaRepository.save(media);
} else {
media.blacklist = Promise.resolve(blacklist);
media.blacklist = blacklist;
media.status = MediaStatus.BLACKLISTED;
media.status4k = MediaStatus.BLACKLISTED;

View File

@@ -118,8 +118,10 @@ class Media {
@OneToMany(() => Issue, (issue) => issue.media, { cascade: true })
public issues: Issue[];
@OneToOne(() => Blacklist, (blacklist) => blacklist.media)
public blacklist: Promise<Blacklist>;
@OneToOne(() => Blacklist, (blacklist) => blacklist.media, {
eager: true,
})
public blacklist: Blacklist;
@CreateDateColumn()
public createdAt: Date;

View File

@@ -1,3 +1,5 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/indexer/themoviedb/constants';
import type { RadarrMovieOptions } from '@server/api/servarr/radarr';
import RadarrAPI from '@server/api/servarr/radarr';
import type {
@@ -5,15 +7,12 @@ import type {
SonarrSeries,
} from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import {
MediaRequestStatus,
MediaStatus,
MediaType,
} from '@server/constants/media';
import { getRepository } from '@server/datasource';
import OverrideRule from '@server/entity/OverrideRule';
import type { MediaRequestBody } from '@server/interfaces/api/requestInterfaces';
import notificationManager, { Notification } from '@server/lib/notifications';
import { Permission } from '@server/lib/permissions';
@@ -714,6 +713,48 @@ export class MediaRequest {
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 radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
@@ -734,151 +775,6 @@ export class MediaRequest {
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) {
let userTag = (await radarr.getTags()).find((v) =>
v.label.startsWith(this.requestedBy.id + ' - ')
@@ -920,6 +816,7 @@ export class MediaRequest {
mediaId: this.media.id,
});
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.APPROVED;
await requestRepository.save(this);
return;
@@ -959,6 +856,8 @@ export class MediaRequest {
await mediaRepository.save(media);
})
.catch(async () => {
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.FAILED;
requestRepository.save(this);
@@ -1058,7 +957,6 @@ export class MediaRequest {
throw new Error('Media data not found');
}
const requestRepository = getRepository(MediaRequest);
if (
media[this.is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
) {
@@ -1068,6 +966,7 @@ export class MediaRequest {
mediaId: this.media.id,
});
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.APPROVED;
await requestRepository.save(this);
return;
@@ -1082,6 +981,7 @@ export class MediaRequest {
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
if (!tvdbId) {
const requestRepository = getRepository(MediaRequest);
await mediaRepository.remove(media);
await requestRepository.remove(this);
throw new Error('TVDB ID not found');
@@ -1119,110 +1019,29 @@ export class MediaRequest {
? [...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 (
this.rootFolder &&
this.rootFolder !== '' &&
this.rootFolder !== rootFolder
) {
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,
}
);
}
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(
`Request has a manually overriden quality profile ID: ${qualityProfile}`,
`Request has an override 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 (
@@ -1242,31 +1061,12 @@ export class MediaRequest {
if (this.tags && !isEqual(this.tags, tags)) {
tags = this.tags;
logger.info(`Request has manually overriden tags`, {
logger.info(`Request has override 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,
});
}
}
if (sonarrSettings.tagRequests) {
@@ -1301,8 +1101,6 @@ export class MediaRequest {
}
}
requestRepository.save(this);
const sonarrSeriesOptions: AddSeriesOptions = {
profileId: qualityProfile,
languageProfileId: languageProfile,
@@ -1339,6 +1137,8 @@ export class MediaRequest {
await mediaRepository.save(media);
})
.catch(async () => {
const requestRepository = getRepository(MediaRequest);
this.status = MediaRequestStatus.FAILED;
requestRepository.save(this);

View File

@@ -1,52 +0,0 @@
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;

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';

View File

@@ -1,3 +0,0 @@
import type OverrideRule from '@server/entity/OverrideRule';
export type OverrideRuleResultsResponse = OverrideRule[];

View File

@@ -8,7 +8,8 @@ export type AvailableCacheIds =
| 'imdb'
| 'github'
| 'plexguid'
| 'plextv';
| 'plextv'
| 'tvdb';
const DEFAULT_TTL = 300;
const DEFAULT_CHECK_PERIOD = 120;
@@ -68,6 +69,10 @@ class CacheManager {
stdTtl: 86400 * 7, // 1 week cache
checkPeriod: 60,
}),
tvdb: new Cache('tvdb', 'The TVDB API', {
stdTtl: 21600,
checkPeriod: 60 * 30,
}),
};
public getCache(id: AvailableCacheIds): Cache {

View File

@@ -291,10 +291,6 @@ class DiscordAgent
}
}
if (settings.options.webhookRoleId) {
userMentions.push(`<@&${settings.options.webhookRoleId}>`);
}
const response = await fetch(settings.options.webhookUrl, {
method: 'POST',
headers: {

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';

View File

@@ -1,7 +1,7 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import type { TmdbTvDetails } from '@server/api/indexer/themoviedb/interfaces';
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
import JellyfinAPI from '@server/api/jellyfin';
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource';

View File

@@ -1,7 +1,7 @@
import animeList from '@server/api/animelist';
import type { TmdbTvDetails } from '@server/api/indexer/themoviedb/interfaces';
import type { PlexLibraryItem, PlexMetadata } from '@server/api/plexapi';
import PlexAPI from '@server/api/plexapi';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import cacheManager from '@server/lib/cache';

View File

@@ -1,6 +1,6 @@
import type { TmdbTvDetails } from '@server/api/indexer/themoviedb/interfaces';
import type { SonarrSeries } from '@server/api/servarr/sonarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import type {

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import type {
TmdbMovieDetails,
TmdbMovieResult,
@@ -9,7 +9,7 @@ import type {
TmdbSearchTvResponse,
TmdbTvDetails,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import {
mapMovieDetailsToResult,
mapPersonDetailsToResult,

View File

@@ -1,3 +1,6 @@
import type { TvShowIndexer } from '@server/api/indexer';
import TheMovieDb from '@server/api/indexer/themoviedb';
import Tvdb from '@server/api/indexer/tvdb';
import { MediaServerType } from '@server/constants/server';
import { Permission } from '@server/lib/permissions';
import { runMigrations } from '@server/lib/settings/migrator';
@@ -76,7 +79,6 @@ export interface DVRSettings {
syncEnabled: boolean;
preventSearch: boolean;
tagRequests: boolean;
overrideRule: number[];
}
export interface RadarrSettings extends DVRSettings {
@@ -171,7 +173,6 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig {
botUsername?: string;
botAvatarUrl?: string;
webhookUrl: string;
webhookRoleId?: string;
enableMentions: boolean;
};
}
@@ -305,6 +306,7 @@ export interface AllSettings {
public: PublicSettings;
notifications: NotificationSettings;
jobs: Record<JobId, JobSettings>;
tvdb: boolean;
}
const SETTINGS_PATH = process.env.CONFIG_DIRECTORY
@@ -370,6 +372,7 @@ class Settings {
apiKey: '',
},
tautulli: {},
tvdb: false,
radarr: [],
sonarr: [],
public: {
@@ -396,7 +399,6 @@ class Settings {
types: 0,
options: {
webhookUrl: '',
webhookRoleId: '',
enableMentions: true,
},
},
@@ -535,6 +537,14 @@ class Settings {
this.data.tautulli = data;
}
get tvdb(): boolean {
return this.data.tvdb;
}
set tvdb(data: boolean) {
this.data.tvdb = data;
}
get radarr(): RadarrSettings[] {
return this.data.radarr;
}
@@ -702,4 +712,13 @@ export const getSettings = (initialSettings?: AllSettings): Settings => {
return settings;
};
export const getIndexer = (): TvShowIndexer => {
const settings = getSettings();
if (settings.tvdb) {
return new Tvdb();
} else {
return new TheMovieDb();
}
};
export default Settings;

View File

@@ -1,15 +0,0 @@
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"`);
}
}

View File

@@ -1,4 +1,4 @@
import type { TmdbCollection } from '@server/api/themoviedb/interfaces';
import type { TmdbCollection } from '@server/api/indexer/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import type Media from '@server/entity/Media';
import { sortBy } from 'lodash';

View File

@@ -2,7 +2,7 @@ import type {
TmdbMovieDetails,
TmdbMovieReleaseResult,
TmdbProductionCompany,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import type Media from '@server/entity/Media';
import type {
Cast,

View File

@@ -2,7 +2,7 @@ import type {
TmdbPersonCreditCast,
TmdbPersonCreditCrew,
TmdbPersonDetails,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import type Media from '@server/entity/Media';
export interface PersonDetails {

View File

@@ -6,7 +6,7 @@ import type {
TmdbPersonResult,
TmdbTvDetails,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import { MediaType as MainMediaType } from '@server/constants/media';
import type Media from '@server/entity/Media';

View File

@@ -5,7 +5,7 @@ import type {
TmdbTvEpisodeResult,
TmdbTvRatingResult,
TmdbTvSeasonResult,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import type Media from '@server/entity/Media';
import type {
Cast,
@@ -124,7 +124,7 @@ const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({
seasonNumber: episode.season_number,
showId: episode.show_id,
voteAverage: episode.vote_average,
voteCount: episode.vote_cuont,
voteCount: episode.vote_count,
stillPath: episode.still_path,
});

View File

@@ -7,7 +7,7 @@ import type {
TmdbVideoResult,
TmdbWatchProviderDetails,
TmdbWatchProviders,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import type { Video } from '@server/models/Movie';
export interface ProductionCompany {

View File

@@ -299,84 +299,54 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
where: { jellyfinUserId: account.User.Id },
});
const missingAdminUser = !user && !(await userRepository.count());
if (
missingAdminUser ||
settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED
) {
if (!user && !(await userRepository.count())) {
// Check if user is admin on jellyfin
if (account.User.Policy.IsAdministrator === false) {
throw new ApiError(403, ApiErrorCode.NotAdmin);
}
if (
body.serverType !== MediaServerType.JELLYFIN &&
body.serverType !== MediaServerType.EMBY
) {
throw new Error('select_server_type');
}
settings.main.mediaServerType = body.serverType;
if (missingAdminUser) {
logger.info(
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Jellyseerr',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
// User doesn't exist, and there are no users in the database, we'll create the user
// with admin permissions
user = new User({
id: 1,
email: body.email || account.User.Name,
logger.info(
'Sign-in attempt from Jellyfin user with access to the media server; creating initial admin user for Overseerr',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: `/avatarproxy/${account.User.Id}`,
userType:
body.serverType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY,
});
await userRepository.save(user);
} else {
logger.info(
'Sign-in attempt from Jellyfin user with access to the media server; editing admin user for Jellyseerr',
{
label: 'API',
ip: req.ip,
jellyfinUsername: account.User.Name,
}
);
// User alread exist but settings.json is not configured, we'll edit the admin user
user = await userRepository.findOne({
where: { id: 1 },
});
if (!user) {
throw new Error('Unable to find admin user to edit');
}
user.email = body.email || account.User.Name;
user.jellyfinUsername = account.User.Name;
user.jellyfinUserId = account.User.Id;
user.jellyfinDeviceId = deviceId;
user.jellyfinAuthToken = account.AccessToken;
user.permissions = Permission.ADMIN;
user.avatar = `/avatarproxy/${account.User.Id}`;
user.userType =
body.serverType === MediaServerType.JELLYFIN
? UserType.JELLYFIN
: UserType.EMBY;
);
await userRepository.save(user);
// User doesn't exist, and there are no users in the database, we'll create the user
// with admin permissions
switch (body.serverType) {
case MediaServerType.EMBY:
settings.main.mediaServerType = MediaServerType.EMBY;
user = new User({
email: body.email || account.User.Name,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: `/avatarproxy/${account.User.Id}`,
userType: UserType.EMBY,
});
break;
case MediaServerType.JELLYFIN:
settings.main.mediaServerType = MediaServerType.JELLYFIN;
user = new User({
email: body.email || account.User.Name,
jellyfinUsername: account.User.Name,
jellyfinUserId: account.User.Id,
jellyfinDeviceId: deviceId,
jellyfinAuthToken: account.AccessToken,
permissions: Permission.ADMIN,
avatar: `/avatarproxy/${account.User.Id}`,
userType: UserType.JELLYFIN,
});
break;
default:
throw new Error('select_server_type');
}
// Create an API key on Jellyfin from this admin user
@@ -398,6 +368,8 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
settings.jellyfin.apiKey = apiKey;
await settings.save();
startJobs();
await userRepository.save(user);
}
// User already exists, let's update their information
else if (account.User.Id === user?.jellyfinUserId) {

View File

@@ -2,12 +2,14 @@ import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import { Blacklist } from '@server/entity/Blacklist';
import Media from '@server/entity/Media';
import { NotFoundError } from '@server/entity/Watchlist';
import type { BlacklistResultsResponse } from '@server/interfaces/api/blacklistInterfaces';
import { Permission } from '@server/lib/permissions';
import logger from '@server/logger';
import { isAuthenticated } from '@server/middleware/auth';
import { Router } from 'express';
import { EntityNotFoundError, QueryFailedError } from 'typeorm';
import rateLimit from 'express-rate-limit';
import { QueryFailedError } from 'typeorm';
import { z } from 'zod';
const blacklistRoutes = Router();
@@ -24,6 +26,7 @@ blacklistRoutes.get(
isAuthenticated([Permission.MANAGE_BLACKLIST, Permission.VIEW_BLACKLIST], {
type: 'or',
}),
rateLimit({ windowMs: 60 * 1000, max: 50 }),
async (req, res, next) => {
const pageSize = req.query.take ? Number(req.query.take) : 25;
const skip = req.query.skip ? Number(req.query.skip) : 0;
@@ -68,32 +71,6 @@ 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(
'/',
isAuthenticated([Permission.MANAGE_BLACKLIST], {
@@ -157,7 +134,7 @@ blacklistRoutes.delete(
return res.status(204).send();
} catch (e) {
if (e instanceof EntityNotFoundError) {
if (e instanceof NotFoundError) {
return next({
status: 401,
message: e.message,

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import Media from '@server/entity/Media';
import logger from '@server/logger';
import { mapCollection } from '@server/models/Collection';

View File

@@ -1,7 +1,7 @@
import type { SortOptions } from '@server/api/indexer/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import type { TmdbKeyword } from '@server/api/indexer/themoviedb/interfaces';
import PlexTvAPI from '@server/api/plextv';
import type { SortOptions } from '@server/api/themoviedb';
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';

View File

@@ -1,10 +1,10 @@
import GithubAPI from '@server/api/github';
import PushoverAPI from '@server/api/pushover';
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import type {
TmdbMovieResult,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import PushoverAPI from '@server/api/pushover';
import { getRepository } from '@server/datasource';
import DiscoverSlider from '@server/entity/DiscoverSlider';
import type { StatusResponse } from '@server/interfaces/api/settingsInterfaces';
@@ -15,7 +15,6 @@ import { checkUser, isAuthenticated } from '@server/middleware/auth';
import { mapWatchProviderDetails } from '@server/models/common';
import { mapProductionCompany } from '@server/models/Movie';
import { mapNetwork } from '@server/models/Tv';
import overrideRuleRoutes from '@server/routes/overrideRule';
import settingsRoutes from '@server/routes/settings';
import watchlistRoutes from '@server/routes/watchlist';
import {
@@ -161,11 +160,6 @@ router.use('/service', isAuthenticated(), serviceRoutes);
router.use('/issue', isAuthenticated(), issueRoutes);
router.use('/issueComment', isAuthenticated(), issueCommentRoutes);
router.use('/auth', authRoutes);
router.use(
'/overrideRule',
isAuthenticated(Permission.ADMIN),
overrideRuleRoutes
);
router.get('/regions', isAuthenticated(), async (req, res, next) => {
const tmdb = new TheMovieDb();

View File

@@ -1,7 +1,7 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TautulliAPI from '@server/api/tautulli';
import TheMovieDb from '@server/api/themoviedb';
import { MediaStatus, MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';

View File

@@ -1,7 +1,7 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import IMDBRadarrProxy from '@server/api/rating/imdbRadarrProxy';
import RottenTomatoes from '@server/api/rating/rottentomatoes';
import { type RatingResponse } from '@server/api/ratings';
import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';

View File

@@ -1,136 +0,0 @@
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;

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import Media from '@server/entity/Media';
import logger from '@server/logger';
import {

View File

@@ -1,5 +1,5 @@
import TheMovieDb from '@server/api/themoviedb';
import type { TmdbSearchMultiResponse } from '@server/api/themoviedb/interfaces';
import TheMovieDb from '@server/api/indexer/themoviedb';
import type { TmdbSearchMultiResponse } from '@server/api/indexer/themoviedb/interfaces';
import Media from '@server/entity/Media';
import { findSearchProvider } from '@server/lib/search';
import logger from '@server/logger';

View File

@@ -1,6 +1,6 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import RadarrAPI from '@server/api/servarr/radarr';
import SonarrAPI from '@server/api/servarr/sonarr';
import TheMovieDb from '@server/api/themoviedb';
import type {
ServiceCommonServer,
ServiceCommonServerWithDetails,

View File

@@ -40,6 +40,7 @@ import { URL } from 'url';
import notificationRoutes from './notifications';
import radarrRoutes from './radarr';
import sonarrRoutes from './sonarr';
import tvdbRoutes from './tvdb';
const settingsRoutes = Router();
@@ -47,6 +48,7 @@ settingsRoutes.use('/notifications', notificationRoutes);
settingsRoutes.use('/radarr', radarrRoutes);
settingsRoutes.use('/sonarr', sonarrRoutes);
settingsRoutes.use('/discover', discoverSettingRoutes);
settingsRoutes.use('/tvdb', tvdbRoutes);
const filteredMainSettings = (
user: User,

View File

@@ -0,0 +1,48 @@
import Tvdb from '@server/api/indexer/tvdb';
import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import { Router } from 'express';
const tvdbRoutes = Router();
export interface TvdbSettings {
tvdb: boolean;
}
tvdbRoutes.get('/', (_req, res) => {
const settings = getSettings();
res.status(200).json({
tvdb: settings.tvdb,
});
});
tvdbRoutes.put('/', (req, res) => {
const settings = getSettings();
const body = req.body as TvdbSettings;
settings.tvdb = body.tvdb ?? settings.tvdb ?? false;
settings.save();
return res.status(200).json({
tvdb: settings.tvdb,
});
});
tvdbRoutes.post('/test', async (req, res, next) => {
try {
const tvdb = new Tvdb();
await tvdb.test();
return res.status(200).json({ message: 'Successfully connected to Tvdb' });
} catch (e) {
logger.error('Failed to test Tvdb', {
label: 'Tvdb',
message: e.message,
});
return next({ status: 500, message: 'Failed to connect to Tvdb' });
}
});
export default tvdbRoutes;

View File

@@ -1,9 +1,10 @@
import TheMovieDb from '@server/api/indexer/themoviedb';
import RottenTomatoes from '@server/api/rating/rottentomatoes';
import TheMovieDb from '@server/api/themoviedb';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { Watchlist } from '@server/entity/Watchlist';
import { getIndexer } from '@server/lib/settings';
import logger from '@server/logger';
import { mapTvResult } from '@server/models/Search';
import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv';
@@ -12,9 +13,10 @@ import { Router } from 'express';
const tvRoutes = Router();
tvRoutes.get('/:id', async (req, res, next) => {
const tmdb = new TheMovieDb();
const indexer = getIndexer();
try {
const tv = await tmdb.getTvShow({
const tv = await indexer.getTvShow({
tvId: Number(req.params.id),
language: (req.query.language as string) ?? req.locale,
});
@@ -34,7 +36,9 @@ tvRoutes.get('/:id', async (req, res, next) => {
// TMDB issue where it doesnt fallback to English when no overview is available in requested locale.
if (!data.overview) {
const tvEnglish = await tmdb.getTvShow({ tvId: Number(req.params.id) });
const tvEnglish = await indexer.getTvShow({
tvId: Number(req.params.id),
});
data.overview = tvEnglish.overview;
}
@@ -53,13 +57,12 @@ tvRoutes.get('/:id', async (req, res, next) => {
});
tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const season = await tmdb.getTvSeason({
const indexer = getIndexer();
const season = await indexer.getTvSeason({
tvId: Number(req.params.id),
seasonNumber: Number(req.params.seasonNumber),
language: (req.query.language as string) ?? req.locale,
});
return res.status(200).json(mapSeasonWithEpisodes(season));

View File

@@ -34,16 +34,8 @@ router.get('/', async (req, res, next) => {
try {
const pageSize = req.query.take ? Number(req.query.take) : 10;
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');
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) {
case 'updated':
query = query.orderBy('user.updatedAt', 'DESC');

View File

@@ -1,5 +1,4 @@
import { ApiErrorCode } from '@server/constants/error';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
import { UserSettings } from '@server/entity/UserSettings';
@@ -100,29 +99,11 @@ userSettingsRoutes.post<
});
}
const oldEmail = user.email;
const oldUsername = user.username;
user.username = req.body.username;
const oldEmail = user.email;
if (user.jellyfinUsername) {
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({
where: { email: user.email },

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import { IssueType, IssueTypeName } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
import { getRepository } from '@server/datasource';

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import { IssueStatus, IssueType, IssueTypeName } from '@server/constants/issue';
import { MediaType } from '@server/constants/media';
import Issue from '@server/entity/Issue';

View File

@@ -1,4 +1,4 @@
import TheMovieDb from '@server/api/themoviedb';
import TheMovieDb from '@server/api/indexer/themoviedb';
import {
MediaRequestStatus,
MediaStatus,

View File

@@ -6,7 +6,7 @@ import type {
TmdbPersonResult,
TmdbTvDetails,
TmdbTvResult,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
export const isMovie = (
movie:

View File

@@ -1,6 +1,5 @@
import Badge from '@app/components/Common/Badge';
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Tooltip from '@app/components/Common/Tooltip';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
@@ -11,7 +10,6 @@ import Link from 'next/link';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages('component.BlacklistBlock', {
blacklistedby: 'Blacklisted By',
@@ -19,13 +17,13 @@ const messages = defineMessages('component.BlacklistBlock', {
});
interface BlacklistBlockProps {
tmdbId: number;
blacklistItem: Blacklist;
onUpdate?: () => void;
onDelete?: () => void;
}
const BlacklistBlock = ({
tmdbId,
blacklistItem,
onUpdate,
onDelete,
}: BlacklistBlockProps) => {
@@ -33,7 +31,6 @@ const BlacklistBlock = ({
const intl = useIntl();
const [isUpdating, setIsUpdating] = useState(false);
const { addToast } = useToasts();
const { data } = useSWR<Blacklist>(`/api/v1/blacklist/${tmdbId}`);
const removeFromBlacklist = async (tmdbId: number, title?: string) => {
setIsUpdating(true);
@@ -65,14 +62,6 @@ const BlacklistBlock = ({
setIsUpdating(false);
};
if (!data) {
return (
<>
<LoadingSpinner />
</>
);
}
return (
<div className="px-4 py-3 text-gray-300">
<div className="flex items-center justify-between">
@@ -84,13 +73,13 @@ const BlacklistBlock = ({
<span className="w-40 truncate md:w-auto">
<Link
href={
data.user.id === user?.id
blacklistItem.user.id === user?.id
? '/profile'
: `/users/${data.user.id}`
: `/users/${blacklistItem.user.id}`
}
>
<span className="font-semibold text-gray-100 transition duration-300 hover:text-white hover:underline">
{data.user.displayName}
{blacklistItem.user.displayName}
</span>
</Link>
</span>
@@ -102,7 +91,9 @@ const BlacklistBlock = ({
>
<Button
buttonType="danger"
onClick={() => removeFromBlacklist(data.tmdbId, data.title)}
onClick={() =>
removeFromBlacklist(blacklistItem.tmdbId, blacklistItem.title)
}
disabled={isUpdating}
>
<TrashIcon className="icon-sm" />
@@ -123,7 +114,7 @@ const BlacklistBlock = ({
<CalendarIcon className="mr-1.5 h-5 w-5 flex-shrink-0" />
</Tooltip>
<span>
{intl.formatDate(data.createdAt, {
{intl.formatDate(blacklistItem.createdAt, {
year: 'numeric',
month: 'long',
day: 'numeric',

View File

@@ -38,7 +38,7 @@ const BlacklistModal = ({
const intl = useIntl();
const { data, error } = useSWR<TvDetails | MovieDetails>(
show ? `/api/v1/${type}/${tmdbId}` : null
`/api/v1/${type}/${tmdbId}`
);
return (

View File

@@ -7,7 +7,7 @@ import { useLockBodyScroll } from '@app/hooks/useLockBodyScroll';
import globalMessages from '@app/i18n/globalMessages';
import { Transition } from '@headlessui/react';
import type { MouseEvent } from 'react';
import React, { Fragment, useEffect, useRef } from 'react';
import React, { Fragment, useRef } from 'react';
import ReactDOM from 'react-dom';
import { useIntl } from 'react-intl';
@@ -66,12 +66,8 @@ const Modal = React.forwardRef<HTMLDivElement, ModalProps>(
) => {
const intl = useIntl();
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, () => {
if (onCancel && backgroundClickableRef.current) {
if (onCancel && backgroundClickable) {
onCancel();
}
});

View File

@@ -9,7 +9,7 @@ import type {
TmdbCompanySearchResponse,
TmdbGenre,
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import { DiscoverSliderType } from '@server/constants/discover';
import type DiscoverSlider from '@server/entity/DiscoverSlider';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';

View File

@@ -5,7 +5,7 @@ import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import type { TmdbKeyword } from '@server/api/indexer/themoviedb/interfaces';
import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useIntl } from 'react-intl';

View File

@@ -13,7 +13,7 @@ import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
import type { SortOptions as TMDBSortOptions } from '@server/api/indexer/themoviedb';
import type { MovieResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useState } from 'react';

View File

@@ -13,7 +13,7 @@ import { useUpdateQueryParams } from '@app/hooks/useUpdateQueryParams';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import { BarsArrowDownIcon, FunnelIcon } from '@heroicons/react/24/solid';
import type { SortOptions as TMDBSortOptions } from '@server/api/themoviedb';
import type { SortOptions as TMDBSortOptions } from '@server/api/indexer/themoviedb';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useState } from 'react';

View File

@@ -5,7 +5,7 @@ import useDiscover, { encodeURIExtraParams } from '@app/hooks/useDiscover';
import globalMessages from '@app/i18n/globalMessages';
import Error from '@app/pages/_error';
import defineMessages from '@app/utils/defineMessages';
import type { TmdbKeyword } from '@server/api/themoviedb/interfaces';
import type { TmdbKeyword } from '@server/api/indexer/themoviedb/interfaces';
import type { TvResult } from '@server/models/Search';
import { useRouter } from 'next/router';
import { useIntl } from 'react-intl';

View File

@@ -7,9 +7,7 @@ import defineMessages from '@app/utils/defineMessages';
import type { TvResult } from '@server/models/Search';
import { useIntl } from 'react-intl';
const messages = defineMessages('components.DiscoverTvUpcoming', {
upcomingtv: 'Upcoming Series',
});
const messages = defineMessages('components.DiscoverTvUpcoming', {});
const DiscoverTvUpcoming = () => {
const intl = useIntl();

View File

@@ -1,7 +1,7 @@
import Spinner from '@app/assets/spinner.svg';
import Tag from '@app/components/Common/Tag';
import { RectangleStackIcon } from '@heroicons/react/24/outline';
import type { TmdbGenre } from '@server/api/themoviedb/interfaces';
import type { TmdbGenre } from '@server/api/indexer/themoviedb/interfaces';
import useSWR from 'swr';
type GenreTagProps = {

View File

@@ -82,17 +82,10 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
port: Yup.number().required(
intl.formatMessage(messages.validationPortRequired)
),
urlBase: Yup.string()
.test(
'leading-slash',
intl.formatMessage(messages.validationUrlBaseLeadingSlash),
(value) => !value || value.startsWith('/')
)
.test(
'trailing-slash',
intl.formatMessage(messages.validationUrlBaseTrailingSlash),
(value) => !value || !value.endsWith('/')
),
urlBase: Yup.string().matches(
/^(.*[^/])$/,
intl.formatMessage(messages.validationUrlBaseTrailingSlash)
),
email: Yup.string()
.email(intl.formatMessage(messages.validationemailformat))
.required(intl.formatMessage(messages.validationemailrequired)),

View File

@@ -292,7 +292,7 @@ const ManageSlideOver = ({
</h3>
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
<BlacklistBlock
tmdbId={data.mediaInfo.tmdbId}
blacklistItem={data.mediaInfo.blacklist}
onUpdate={() => revalidate()}
onDelete={() => onClose()}
/>

View File

@@ -88,14 +88,14 @@ const SearchByNameModal = ({
tvdbId === item.tvdbId ? 'ring ring-indigo-500' : ''
} `}
>
<div className="relative flex w-24 flex-none items-center space-x-4 self-stretch">
<div className="flex w-24 flex-none items-center space-x-4">
<Image
src={
item.remotePoster ??
'/images/overseerr_poster_not_found.png'
}
alt={item.title}
className="w-100 h-auto rounded-md"
className="h-100 w-auto rounded-md"
fill
/>
</div>

View File

@@ -9,7 +9,7 @@ import useSettings from '@app/hooks/useSettings';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import { ANIME_KEYWORD_ID } from '@server/api/indexer/themoviedb/constants';
import { MediaRequestStatus, MediaStatus } from '@server/constants/media';
import type { MediaRequest } from '@server/entity/MediaRequest';
import type SeasonRequest from '@server/entity/SeasonRequest';

View File

@@ -11,9 +11,8 @@ import type {
TmdbCompanySearchResponse,
TmdbGenre,
TmdbKeywordSearchResponse,
} from '@server/api/themoviedb/interfaces';
} from '@server/api/indexer/themoviedb/interfaces';
import type { GenreSliderItem } from '@server/interfaces/api/discoverInterfaces';
import type { UserResultsResponse } from '@server/interfaces/api/userInterfaces';
import type {
Keyword,
ProductionCompany,
@@ -30,7 +29,6 @@ const messages = defineMessages('components.Selector', {
searchKeywords: 'Search keywords…',
searchGenres: 'Select genres…',
searchStudios: 'Search studios…',
searchUsers: 'Select users…',
starttyping: 'Starting typing to search.',
nooptions: 'No results.',
showmore: 'Show More',
@@ -439,7 +437,7 @@ export const WatchProviderSelector = ({
key={`prodiver-${provider.id}`}
>
<div
className={`provider-container w-full cursor-pointer rounded-lg ring-1 ${
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 ${
isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
@@ -453,15 +451,18 @@ export const WatchProviderSelector = ({
role="button"
tabIndex={0}
>
<div className="relative m-2 aspect-1">
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
fill
className="rounded-lg object-contain"
/>
</div>
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
}}
fill
className="rounded-lg"
/>
{isActive && (
<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" />
@@ -482,7 +483,7 @@ export const WatchProviderSelector = ({
key={`prodiver-${provider.id}`}
>
<div
className={`provider-container w-full cursor-pointer rounded-lg ring-1 transition ${
className={`provider-container relative w-full cursor-pointer rounded-lg p-2 ring-1 transition ${
isActive
? 'bg-gray-600 ring-indigo-500 hover:bg-gray-500'
: 'bg-gray-700 ring-gray-500 hover:bg-gray-600'
@@ -496,15 +497,18 @@ export const WatchProviderSelector = ({
role="button"
tabIndex={0}
>
<div className="relative m-2 aspect-1">
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
fill
className="rounded-lg object-contain"
/>
</div>
<CachedImage
type="tmdb"
src={`https://image.tmdb.org/t/p/original${provider.logoPath}`}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
fill
className="rounded-lg"
/>
{isActive && (
<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" />
@@ -544,77 +548,3 @@ 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);
}}
/>
);
};

View File

@@ -19,16 +19,12 @@ const messages = defineMessages('components.Settings.Notifications', {
webhookUrl: 'Webhook URL',
webhookUrlTip:
'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!',
discordsettingsfailed: 'Discord notification settings failed to save.',
toastDiscordTestSending: 'Sending Discord test notification…',
toastDiscordTestSuccess: 'Discord test notification sent!',
toastDiscordTestFailed: 'Discord test notification failed to send.',
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',
enableMentions: 'Enable Mentions',
});
@@ -57,12 +53,6 @@ const NotificationsDiscord = () => {
otherwise: Yup.string().nullable(),
})
.url(intl.formatMessage(messages.validationUrl)),
webhookRoleId: Yup.string()
.nullable()
.matches(
/^\d{17,19}$/,
intl.formatMessage(messages.validationWebhookRoleId)
),
});
if (!data && !error) {
@@ -77,7 +67,6 @@ const NotificationsDiscord = () => {
botUsername: data?.options.botUsername,
botAvatarUrl: data?.options.botAvatarUrl,
webhookUrl: data.options.webhookUrl,
webhookRoleId: data?.options.webhookRoleId,
enableMentions: data?.options.enableMentions,
}}
validationSchema={NotificationsDiscordSchema}
@@ -95,7 +84,6 @@ const NotificationsDiscord = () => {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
webhookRoleId: values.webhookRoleId,
enableMentions: values.enableMentions,
},
}),
@@ -153,7 +141,6 @@ const NotificationsDiscord = () => {
botUsername: values.botUsername,
botAvatarUrl: values.botAvatarUrl,
webhookUrl: values.webhookUrl,
webhookRoleId: values.webhookRoleId,
enableMentions: values.enableMentions,
},
}),
@@ -267,21 +254,6 @@ const NotificationsDiscord = () => {
)}
</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">
<label htmlFor="enableMentions" className="checkbox-label">
{intl.formatMessage(messages.enableMentions)}

View File

@@ -1,391 +0,0 @@
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;

View File

@@ -1,267 +0,0 @@
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;

View File

@@ -1,21 +1,14 @@
import Button from '@app/components/Common/Button';
import Modal from '@app/components/Common/Modal';
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 defineMessages from '@app/utils/defineMessages';
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 { Field, Formik } from 'formik';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import Select from 'react-select';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
type OptionType = {
@@ -76,46 +69,41 @@ const messages = defineMessages('components.Settings.RadarrModal', {
announced: 'Announced',
inCinemas: 'In Cinemas',
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 {
radarr: RadarrSettings | null;
onClose: () => 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,
overrideRuleModal,
setOverrideRuleModal,
}: RadarrModalProps) => {
const RadarrModal = ({ onClose, radarr, onSave }: RadarrModalProps) => {
const intl = useIntl();
const { data: rules, mutate: revalidate } =
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
const initialLoad = useRef(false);
const { addToast } = useToasts();
const [isValidated, setIsValidated] = useState(radarr ? true : false);
const [isTesting, setIsTesting] = useState(false);
const [testResponse, setTestResponse] = useState<DVRTestResponse>({
const [testResponse, setTestResponse] = useState<TestResponse>({
profiles: [],
rootFolders: [],
tags: [],
});
const RadarrSettingsSchema = Yup.object().shape({
name: Yup.string().required(
intl.formatMessage(messages.validationNameRequired)
@@ -142,10 +130,7 @@ const RadarrModal = ({
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
),
externalUrl: Yup.string()
.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)
)
.url(intl.formatMessage(messages.validationApplicationUrl))
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
@@ -232,10 +217,6 @@ const RadarrModal = ({
}
}, [radarr, testConnection]);
useEffect(() => {
revalidate();
}, [overrideRuleModal, revalidate]);
return (
<Transition
as="div"
@@ -379,7 +360,6 @@ const RadarrModal = ({
values.is4k ? messages.edit4kradarr : messages.editradarr
)
}
backgroundClickable={!overrideRuleModal.open}
>
<div className="mb-6">
<div className="form-row">
@@ -770,38 +750,6 @@ const RadarrModal = ({
</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>
);
}}

View File

@@ -139,10 +139,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
),
jellyfinExternalUrl: Yup.string()
.nullable()
.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)
)
.url(intl.formatMessage(messages.validationUrl))
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationUrlTrailingSlash),
@@ -150,10 +147,7 @@ const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({
),
jellyfinForgotPasswordUrl: Yup.string()
.nullable()
.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)
)
.url(intl.formatMessage(messages.validationUrl))
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationUrlTrailingSlash),

View File

@@ -37,6 +37,11 @@ const SettingsLayout = ({ children }: SettingsLayoutProps) => {
route: '/settings/users',
regex: /^\/settings\/users/,
},
{
text: 'Tvdb',
route: '/settings/tvdb',
regex: /^\/settings\/tvdb/,
},
settings.currentSettings.mediaServerType === MediaServerType.PLEX
? {
text: intl.formatMessage(messages.menuPlexSettings),

View File

@@ -87,10 +87,7 @@ const SettingsMain = () => {
intl.formatMessage(messages.validationApplicationTitle)
),
applicationUrl: Yup.string()
.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)
)
.url(intl.formatMessage(messages.validationApplicationUrl))
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),

View File

@@ -190,10 +190,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
otherwise: Yup.string().nullable(),
}),
tautulliExternalUrl: Yup.string()
.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)
)
.url(intl.formatMessage(messages.validationUrl))
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationUrlTrailingSlash),

View File

@@ -6,14 +6,12 @@ import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import Modal from '@app/components/Common/Modal';
import PageTitle from '@app/components/Common/PageTitle';
import OverrideRuleModal from '@app/components/Settings/OverrideRule/OverrideRuleModal';
import RadarrModal from '@app/components/Settings/RadarrModal';
import SonarrModal from '@app/components/Settings/SonarrModal';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { Transition } from '@headlessui/react';
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 { Fragment, useState } from 'react';
import { useIntl } from 'react-intl';
@@ -59,22 +57,6 @@ interface ServerInstanceProps {
onDelete: () => void;
}
export interface DVRTestResponse {
profiles: {
id: number;
name: string;
}[];
rootFolders: {
id: number;
path: string;
}[];
tags: {
id: number;
label: string;
}[];
urlBase?: string;
}
const ServerInstance = ({
name,
hostname,
@@ -211,15 +193,6 @@ const SettingsServices = () => {
type: 'radarr',
serverId: null,
});
const [overrideRuleModal, setOverrideRuleModal] = useState<{
open: boolean;
rule: OverrideRule | null;
testResponse: DVRTestResponse | null;
}>({
open: false,
rule: null,
testResponse: null,
});
const deleteServer = async () => {
const res = await fetch(
@@ -254,51 +227,26 @@ const SettingsServices = () => {
})}
</p>
</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 && (
<RadarrModal
radarr={editRadarrModal.radarr}
onClose={() => {
if (!overrideRuleModal.open)
setEditRadarrModal({ open: false, radarr: null });
}}
onClose={() => setEditRadarrModal({ open: false, radarr: null })}
onSave={() => {
revalidateRadarr();
mutate('/api/v1/settings/public');
setEditRadarrModal({ open: false, radarr: null });
}}
overrideRuleModal={overrideRuleModal}
setOverrideRuleModal={setOverrideRuleModal}
/>
)}
{editSonarrModal.open && (
<SonarrModal
sonarr={editSonarrModal.sonarr}
onClose={() => {
if (!overrideRuleModal.open)
setEditSonarrModal({ open: false, sonarr: null });
}}
onClose={() => setEditSonarrModal({ open: false, sonarr: null })}
onSave={() => {
revalidateSonarr();
mutate('/api/v1/settings/public');
setEditSonarrModal({ open: false, sonarr: null });
}}
overrideRuleModal={overrideRuleModal}
setOverrideRuleModal={setOverrideRuleModal}
/>
)}
<Transition

View File

@@ -0,0 +1,198 @@
import Button from '@app/components/Common/Button';
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
import PageTitle from '@app/components/Common/PageTitle';
import SettingsBadge from '@app/components/Settings/SettingsBadge';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import type { TvdbSettings } from '@server/routes/settings/tvdb';
import { Field, Form, Formik } from 'formik';
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
const messages = defineMessages('components.Settings', {
general: 'General',
settings: 'Settings',
apikey: 'API Key',
pin: 'PIN',
enable: 'Enable',
enableTip:
'Enable Tvdb (only for season and episode).' +
' Due to a limitation of the api used, only English is available.',
});
const SettingsTvdb = () => {
const intl = useIntl();
const [isTesting, setIsTesting] = useState(false);
const { addToast } = useToasts();
const testConnection = async () => {
const response = await fetch('/api/v1/settings/tvdb/test', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to test Tvdb');
}
};
const saveSettings = async (value: TvdbSettings) => {
const response = await fetch('/api/v1/settings/tvdb', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
tvdb: value.tvdb,
}),
});
if (!response.ok) {
throw new Error('Failed to save Tvdb settings');
}
};
const { data, error } = useSWR<TvdbSettings>('/api/v1/settings/tvdb');
if (!data && !error) {
return <LoadingSpinner />;
}
return (
<>
<PageTitle
title={[
intl.formatMessage(messages.general),
intl.formatMessage(globalMessages.settings),
]}
/>
<div className="mb-6">
<h3 className="heading">{'Tvdb'}</h3>
<p className="description">{'Settings for Tvdb'}</p>
</div>
<div className="section">
<Formik
initialValues={{
enable: data?.tvdb ?? false,
}}
onSubmit={async (values) => {
try {
setIsTesting(true);
await testConnection();
setIsTesting(false);
} catch (e) {
addToast('Tvdb connection error, check your credentials', {
appearance: 'error',
});
return;
}
try {
await saveSettings({
tvdb: values.enable ?? false,
});
if (data) {
data.tvdb = values.enable;
}
} catch (e) {
addToast('Failed to save Tvdb settings', { appearance: 'error' });
return;
}
addToast('Tvdb settings saved', { appearance: 'success' });
}}
>
{({ isSubmitting, isValid, values, setFieldValue }) => {
return (
<Form className="section" data-testid="settings-main-form">
<div className="form-row">
<label htmlFor="trustProxy" className="checkbox-label">
<span className="mr-2">
{intl.formatMessage(messages.enable)}
</span>
<SettingsBadge badgeType="experimental" />
<span className="label-tip">
{intl.formatMessage(messages.enableTip)}
</span>
</label>
<div className="form-input-area">
<Field
data-testid="tvdb-enable"
type="checkbox"
id="enable"
name="enable"
onChange={() => {
setFieldValue('enable', !values.enable);
addToast('Tvdb connection successful', {
appearance: 'success',
});
}}
/>
</div>
<div className="error"></div>
</div>
<div className="actions">
<div className="flex justify-end">
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="warning"
type="button"
disabled={isSubmitting || !isValid}
onClick={async () => {
setIsTesting(true);
try {
await testConnection();
addToast('Tvdb connection successful', {
appearance: 'success',
});
} catch (e) {
addToast(
'Tvdb connection error, check your credentials',
{ appearance: 'error' }
);
}
setIsTesting(false);
}}
>
<BeakerIcon />
<span>
{isTesting
? intl.formatMessage(globalMessages.testing)
: intl.formatMessage(globalMessages.test)}
</span>
</Button>
</span>
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
data-testid="tvbd-save-button"
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
<ArrowDownOnSquareIcon />
<span>
{isSubmitting
? intl.formatMessage(globalMessages.saving)
: intl.formatMessage(globalMessages.save)}
</span>
</Button>
</span>
</div>
</div>
</Form>
);
}}
</Formik>
</div>
</>
);
};
export default SettingsTvdb;

View File

@@ -1,14 +1,8 @@
import Button from '@app/components/Common/Button';
import Modal from '@app/components/Common/Modal';
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 defineMessages from '@app/utils/defineMessages';
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 { Field, Formik } from 'formik';
import { useCallback, useEffect, useRef, useState } from 'react';
@@ -16,7 +10,6 @@ import { useIntl } from 'react-intl';
import type { OnChangeValue } from 'react-select';
import Select from 'react-select';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
type OptionType = {
@@ -82,56 +75,48 @@ const messages = defineMessages('components.Settings.SonarrModal', {
animeTags: 'Anime Tags',
notagoptions: 'No tags.',
selecttags: 'Select tags',
overrideRules: 'Override Rules',
addrule: 'New Override Rule',
});
interface SonarrTestResponse extends DVRTestResponse {
interface TestResponse {
profiles: {
id: number;
name: string;
}[];
rootFolders: {
id: number;
path: string;
}[];
languageProfiles:
| {
id: number;
name: string;
}[]
| null;
tags: {
id: number;
label: string;
}[];
urlBase?: string;
}
interface SonarrModalProps {
sonarr: SonarrSettings | null;
onClose: () => 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,
overrideRuleModal,
setOverrideRuleModal,
}: SonarrModalProps) => {
const SonarrModal = ({ onClose, sonarr, onSave }: SonarrModalProps) => {
const intl = useIntl();
const { data: rules, mutate: revalidate } =
useSWR<OverrideRuleResultsResponse>('/api/v1/overrideRule');
const initialLoad = useRef(false);
const { addToast } = useToasts();
const [isValidated, setIsValidated] = useState(sonarr ? true : false);
const [isTesting, setIsTesting] = useState(false);
const [testResponse, setTestResponse] = useState<SonarrTestResponse>({
const [testResponse, setTestResponse] = useState<TestResponse>({
profiles: [],
rootFolders: [],
languageProfiles: null,
tags: [],
});
const SonarrSettingsSchema = Yup.object().shape({
name: Yup.string().required(
intl.formatMessage(messages.validationNameRequired)
@@ -160,10 +145,7 @@ const SonarrModal = ({
)
: Yup.number(),
externalUrl: Yup.string()
.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)
)
.url(intl.formatMessage(messages.validationApplicationUrl))
.test(
'no-trailing-slash',
intl.formatMessage(messages.validationApplicationUrlTrailingSlash),
@@ -212,7 +194,7 @@ const SonarrModal = ({
}),
});
if (!res.ok) throw new Error();
const data: SonarrTestResponse = await res.json();
const data: TestResponse = await res.json();
setIsValidated(true);
setTestResponse(data);
@@ -250,10 +232,6 @@ const SonarrModal = ({
}
}, [sonarr, testConnection]);
useEffect(() => {
revalidate();
}, [overrideRuleModal, revalidate]);
return (
<Transition
as="div"
@@ -421,7 +399,6 @@ const SonarrModal = ({
values.is4k ? messages.edit4ksonarr : messages.editsonarr
)
}
backgroundClickable={!overrideRuleModal.open}
>
<div className="mb-6">
<div className="form-row">
@@ -1076,38 +1053,6 @@ const SonarrModal = ({
</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>
);
}}

View File

@@ -59,7 +59,7 @@ const Season = ({ seasonNumber, tvId }: SeasonProps) => {
<div className="relative aspect-video xl:h-32">
<Image
className="rounded-lg object-contain"
src={`https://image.tmdb.org/t/p/original/${episode.stillPath}`}
src={episode.stillPath}
alt=""
fill
/>

View File

@@ -48,8 +48,8 @@ import {
MinusCircleIcon,
StarIcon,
} from '@heroicons/react/24/solid';
import { ANIME_KEYWORD_ID } from '@server/api/indexer/themoviedb/constants';
import type { RTRating } from '@server/api/rating/rottentomatoes';
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
import { IssueStatus } from '@server/constants/issue';
import {
MediaRequestStatus,
@@ -119,9 +119,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
const intl = useIntl();
const { locale } = useLocale();
const [showRequestModal, setShowRequestModal] = useState(false);
const [showManager, setShowManager] = useState(
router.query.manage == '1' ? true : false
);
const [showManager, setShowManager] = useState(router.query.manage == '1');
const [showIssueModal, setShowIssueModal] = useState(false);
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [toggleWatchlist, setToggleWatchlist] = useState<boolean>(
@@ -157,7 +155,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
);
useEffect(() => {
setShowManager(router.query.manage == '1' ? true : false);
setShowManager(router.query.manage == '1');
}, [router.query.manage]);
const closeBlacklistModal = useCallback(
@@ -189,7 +187,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
})
) {
mediaLinks.push({
text: getAvalaibleMediaServerName(),
text: getAvailableMediaServerName(),
url: plexUrl,
svg: <PlayIcon />,
});
@@ -203,7 +201,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
})
) {
mediaLinks.push({
text: getAvalaible4kMediaServerName(),
text: getAvailable4kMediaServerName(),
url: plexUrl4k,
svg: <PlayIcon />,
});
@@ -307,7 +305,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
data?.watchProviders?.find((provider) => provider.iso_3166_1 === region)
?.flatrate ?? [];
function getAvalaibleMediaServerName() {
function getAvailableMediaServerName() {
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
}
@@ -319,7 +317,7 @@ const TvDetails = ({ tv }: TvDetailsProps) => {
return intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' });
}
function getAvalaible4kMediaServerName() {
function getAvailable4kMediaServerName() {
if (settings.currentSettings.mediaServerType === MediaServerType.EMBY) {
return intl.formatMessage(messages.play, { mediaServerName: 'Emby' });
}

View File

@@ -44,8 +44,6 @@ const messages = defineMessages(
toastSettingsSuccess: 'Settings saved successfully!',
toastSettingsFailure: 'Something went wrong while saving settings.',
toastSettingsFailureEmail: 'This email is already taken!',
toastSettingsFailureEmailEmpty:
'Another user already has this username. You must set an email',
region: 'Discover Region',
regionTip: 'Filter content by regional availability',
originallanguage: 'Discover Language',
@@ -140,7 +138,7 @@ const UserGeneralSettings = () => {
</div>
<Formik
initialValues={{
displayName: data?.username !== user?.email ? data?.username : '',
displayName: data?.username ?? '',
email: data?.email?.includes('@') ? data.email : '',
discordId: data?.discordId ?? '',
locale: data?.locale,
@@ -205,23 +203,10 @@ const UserGeneralSettings = () => {
/* empty */
}
if (errorData?.message === ApiErrorCode.InvalidEmail) {
if (values.email) {
addToast(
intl.formatMessage(messages.toastSettingsFailureEmail),
{
autoDismiss: true,
appearance: 'error',
}
);
} else {
addToast(
intl.formatMessage(messages.toastSettingsFailureEmailEmpty),
{
autoDismiss: true,
appearance: 'error',
}
);
}
addToast(intl.formatMessage(messages.toastSettingsFailureEmail), {
autoDismiss: true,
appearance: 'error',
});
} else {
addToast(intl.formatMessage(messages.toastSettingsFailure), {
autoDismiss: true,
@@ -299,9 +284,9 @@ const UserGeneralSettings = () => {
name="displayName"
type="text"
placeholder={
user?.username ||
user?.jellyfinUsername ||
user?.plexUsername ||
user?.email
user?.plexUsername
}
/>
</div>

View File

@@ -25,14 +25,15 @@ async function extractMessages(
try {
const formattedMessages = messages
.trim()
.replace(/^\s*(['"])?([a-zA-Z0-9_-]+)(['"])?:[\s\n]*/gm, '"$2":')
.replace(/^"[a-zA-Z0-9_-]+":'.*',?$/gm, (match) => {
const parts = /^("[a-zA-Z0-9_-]+":)'(.*)',?$/.exec(match);
if (!parts) return match;
return `${parts[1]}"${parts[2]
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')}",`;
})
.replace(/^\s*(['"])?([a-zA-Z0-9_-]+)(['"])?:/gm, '"$2":')
.replace(
/'.*'/g,
(match) =>
`"${match
.match(/'(.*)'/)?.[1]
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')}"`
)
.replace(/,$/, '');
const messagesJson = JSON.parse(`{${formattedMessages}}`);
return { namespace: namespace.trim(), messages: messagesJson };

View File

@@ -100,7 +100,6 @@
"components.Discover.StudioSlider.studios": "Studios",
"components.Discover.TvGenreList.seriesgenres": "Series Genres",
"components.Discover.TvGenreSlider.tvgenres": "Series Genres",
"components.DiscoverTvUpcoming.upcomingtv": "Upcoming Series",
"components.Discover.createnewslider": "Create New Slider",
"components.Discover.customizediscover": "Customize Discover",
"components.Discover.discover": "Discover",
@@ -299,6 +298,7 @@
"components.ManageSlideOver.plays": "<strong>{playCount, number}</strong> {playCount, plural, one {play} other {plays}}",
"components.ManageSlideOver.removearr": "Remove from {arr}",
"components.ManageSlideOver.removearr4k": "Remove from 4K {arr}",
"components.RequestList.RequestItem.removearr": "Remove from {arr}",
"components.ManageSlideOver.tvshow": "series",
"components.MediaSlider.ShowMoreCard.seemore": "See More",
"components.MovieDetails.MovieCast.fullcast": "Full Cast",
@@ -494,7 +494,6 @@
"components.RequestList.RequestItem.modified": "Modified",
"components.RequestList.RequestItem.modifieduserdate": "{date} by {user}",
"components.RequestList.RequestItem.profileName": "Profile",
"components.RequestList.RequestItem.removearr": "Remove from {arr}",
"components.RequestList.RequestItem.requested": "Requested",
"components.RequestList.RequestItem.requesteddate": "Requested",
"components.RequestList.RequestItem.seasons": "{seasonCount, plural, one {Season} other {Seasons}}",
@@ -590,7 +589,6 @@
"components.Selector.searchKeywords": "Search keywords…",
"components.Selector.searchStatus": "Select status...",
"components.Selector.searchStudios": "Search studios…",
"components.Selector.searchUsers": "Select users…",
"components.Selector.showless": "Show Less",
"components.Selector.showmore": "Show More",
"components.Selector.starttyping": "Starting typing to search.",
@@ -729,63 +727,37 @@
"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.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.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.addrule": "New Override Rule",
"components.Settings.RadarrModal.announced": "Announced",
"components.Settings.RadarrModal.apiKey": "API Key",
"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.createradarr": "Add New Radarr Server",
"components.Settings.RadarrModal.createrule": "New Override Rule",
"components.Settings.RadarrModal.default4kserver": "Default 4K Server",
"components.Settings.RadarrModal.defaultserver": "Default Server",
"components.Settings.RadarrModal.edit4kradarr": "Edit 4K 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.externalUrl": "External URL",
"components.Settings.RadarrModal.genres": "Genres",
"components.Settings.RadarrModal.hostname": "Hostname or IP Address",
"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.loadingprofiles": "Loading quality profiles…",
"components.Settings.RadarrModal.loadingrootfolders": "Loading root folders…",
"components.Settings.RadarrModal.minimumAvailability": "Minimum Availability",
"components.Settings.RadarrModal.notagoptions": "No tags.",
"components.Settings.RadarrModal.overrideRules": "Override Rules",
"components.Settings.RadarrModal.port": "Port",
"components.Settings.RadarrModal.qualityprofile": "Quality Profile",
"components.Settings.RadarrModal.released": "Released",
"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.selectQualityProfile": "Select quality profile",
"components.Settings.RadarrModal.selectRootFolder": "Select root folder",
"components.Settings.RadarrModal.selecttags": "Select tags",
"components.Settings.RadarrModal.server4k": "4K Server",
"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.syncEnabled": "Enable Scan",
"components.Settings.RadarrModal.tagRequests": "Tag Requests",
@@ -796,7 +768,6 @@
"components.Settings.RadarrModal.testFirstTags": "Test connection to load tags",
"components.Settings.RadarrModal.toastRadarrTestFailure": "Failed to connect to Radarr.",
"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.validationApplicationUrl": "You must provide a valid URL",
"components.Settings.RadarrModal.validationApplicationUrlTrailingSlash": "URL must not end in a trailing slash",
@@ -914,15 +885,6 @@
"components.Settings.SettingsMain.originallanguage": "Discover Language",
"components.Settings.SettingsMain.originallanguageTip": "Filter content by original language",
"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.regionTip": "Filter content by regional availability",
"components.Settings.SettingsMain.toastApiKeyFailure": "Something went wrong while generating a new API key.",
@@ -934,7 +896,6 @@
"components.Settings.SettingsMain.validationApplicationTitle": "You must provide an application title",
"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.validationProxyPort": "You must provide a valid port",
"components.Settings.SettingsUsers.defaultPermissions": "Default Permissions",
"components.Settings.SettingsUsers.defaultPermissionsTip": "Initial permissions assigned to new users",
"components.Settings.SettingsUsers.localLogin": "Enable Local Sign-In",
@@ -949,7 +910,6 @@
"components.Settings.SettingsUsers.userSettingsDescription": "Configure global and default user settings.",
"components.Settings.SettingsUsers.users": "Users",
"components.Settings.SonarrModal.add": "Add Server",
"components.Settings.SonarrModal.addrule": "New Override Rule",
"components.Settings.SonarrModal.animeSeriesType": "Anime Series Type",
"components.Settings.SonarrModal.animeTags": "Anime Tags",
"components.Settings.SonarrModal.animelanguageprofile": "Anime Language Profile",
@@ -972,7 +932,6 @@
"components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…",
"components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…",
"components.Settings.SonarrModal.notagoptions": "No tags.",
"components.Settings.SonarrModal.overrideRules": "Override Rules",
"components.Settings.SonarrModal.port": "Port",
"components.Settings.SonarrModal.qualityprofile": "Quality Profile",
"components.Settings.SonarrModal.rootfolder": "Root Folder",
@@ -1125,7 +1084,7 @@
"components.Setup.finishing": "Finishing…",
"components.Setup.servertype": "Choose Server Type",
"components.Setup.setup": "Setup",
"components.Setup.signin": "Sign In",
"components.Setup.signin": "Sign in to your account",
"components.Setup.signinMessage": "Get started by signing in",
"components.Setup.signinWithEmby": "Enter your Emby details",
"components.Setup.signinWithJellyfin": "Enter your Jellyfin details",
@@ -1276,7 +1235,6 @@
"components.UserProfile.UserSettings.UserGeneralSettings.seriesrequestlimit": "Series Request Limit",
"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.toastSettingsFailureEmailEmpty": "Another user already has this username. You must set an email",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!",
"components.UserProfile.UserSettings.UserGeneralSettings.user": "User",
"components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID",

View File

@@ -0,0 +1,16 @@
import SettingsLayout from '@app/components/Settings/SettingsLayout';
import SettingsTvdb from '@app/components/Settings/SettingsTvdb';
import useRouteGuard from '@app/hooks/useRouteGuard';
import { Permission } from '@app/hooks/useUser';
import type { NextPage } from 'next';
const TvdbSettingsPage: NextPage = () => {
useRouteGuard(Permission.ADMIN);
return (
<SettingsLayout>
<SettingsTvdb />
</SettingsLayout>
);
};
export default TvdbSettingsPage;

View File

@@ -1,26 +1,18 @@
import { defineMessages as intlDefineMessages } from 'react-intl';
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>>(
export default function defineMessages(
prefix: string,
messages: T
): Messages<T> {
const keys: (keyof T)[] = Object.keys(messages);
const modifiedMessagesEntries = keys.map((key) => [
key,
{
id: `${prefix}.${key as string}`,
messages: Record<string, string>
) {
const modifiedMessages: Record<
string,
{ id: string; defaultMessage: string }
> = {};
for (const key of Object.keys(messages)) {
modifiedMessages[key] = {
id: prefix + '.' + key,
defaultMessage: messages[key],
},
]);
const modifiedMessages: Messages<T> = Object.fromEntries(
modifiedMessagesEntries
);
};
}
return intlDefineMessages(modifiedMessages);
}