feat: radarr/sonarr tag support (#1366)

pull/1361/head
sct 4 years ago committed by GitHub
parent db5af6d24b
commit a306ebc2d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -441,6 +441,15 @@ components:
- is4k
- enableSeasonFolders
- isDefault
ServarrTag:
type: object
properties:
id:
type: number
example: 1
label:
type: string
example: A Label
PublicSettings:
type: object
properties:

@ -0,0 +1,169 @@
import cacheManager, { AvailableCacheIds } from '../../lib/cache';
import { DVRSettings } from '../../lib/settings';
import ExternalAPI from '../externalapi';
export interface RootFolder {
id: number;
path: string;
freeSpace: number;
totalSpace: number;
unmappedFolders: {
name: string;
path: string;
}[];
}
export interface QualityProfile {
id: number;
name: string;
}
interface QueueItem {
size: number;
title: string;
sizeleft: number;
timeleft: string;
estimatedCompletionTime: string;
status: string;
trackedDownloadStatus: string;
trackedDownloadState: string;
downloadId: string;
protocol: string;
downloadClient: string;
indexer: string;
id: number;
}
export interface Tag {
id: number;
label: string;
}
interface QueueResponse<QueueItemAppendT> {
page: number;
pageSize: number;
sortKey: string;
sortDirection: string;
totalRecords: number;
records: (QueueItem & QueueItemAppendT)[];
}
class ServarrBase<QueueItemAppendT> extends ExternalAPI {
static buildUrl(settings: DVRSettings, path?: string): string {
return `${settings.useSsl ? 'https' : 'http'}://${settings.hostname}:${
settings.port
}${settings.baseUrl ?? ''}${path}`;
}
protected apiName: string;
constructor({
url,
apiKey,
cacheName,
apiName,
}: {
url: string;
apiKey: string;
cacheName: AvailableCacheIds;
apiName: string;
}) {
super(
url,
{
apikey: apiKey,
},
{
nodeCache: cacheManager.getCache(cacheName).data,
}
);
this.apiName = apiName;
}
public getProfiles = async (): Promise<QualityProfile[]> => {
try {
const data = await this.getRolling<QualityProfile[]>(
`/qualityProfile`,
undefined,
3600
);
return data;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve profiles: ${e.message}`
);
}
};
public getRootFolders = async (): Promise<RootFolder[]> => {
try {
const data = await this.getRolling<RootFolder[]>(
`/rootfolder`,
undefined,
3600
);
return data;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve root folders: ${e.message}`
);
}
};
public getQueue = async (): Promise<(QueueItem & QueueItemAppendT)[]> => {
try {
const response = await this.axios.get<QueueResponse<QueueItemAppendT>>(
`/queue`
);
return response.data.records;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve queue: ${e.message}`
);
}
};
public getTags = async (): Promise<Tag[]> => {
try {
const response = await this.axios.get<Tag[]>(`/tag`);
return response.data;
} catch (e) {
throw new Error(
`[${this.apiName}] Failed to retrieve tags: ${e.message}`
);
}
};
public createTag = async ({ label }: { label: string }): Promise<Tag> => {
try {
const response = await this.axios.post<Tag>(`/tag`, {
label,
});
return response.data;
} catch (e) {
throw new Error(`[${this.apiName}] Failed to create tag: ${e.message}`);
}
};
protected async runCommand(
commandName: string,
options: Record<string, unknown>
): Promise<void> {
try {
await this.axios.post(`/command`, {
name: commandName,
...options,
});
} catch (e) {
throw new Error(`[${this.apiName}] Failed to run command: ${e.message}`);
}
}
}
export default ServarrBase;

@ -1,12 +1,11 @@
import cacheManager from '../lib/cache';
import { RadarrSettings } from '../lib/settings';
import logger from '../logger';
import ExternalAPI from './externalapi';
import logger from '../../logger';
import ServarrBase from './base';
interface RadarrMovieOptions {
title: string;
qualityProfileId: number;
minimumAvailability: string;
tags: number[];
profileId: number;
year: number;
rootFolderPath: string;
@ -32,65 +31,9 @@ export interface RadarrMovie {
hasFile: boolean;
}
export interface RadarrRootFolder {
id: number;
path: string;
freeSpace: number;
totalSpace: number;
unmappedFolders: {
name: string;
path: string;
}[];
}
export interface RadarrProfile {
id: number;
name: string;
}
interface QueueItem {
movieId: number;
size: number;
title: string;
sizeleft: number;
timeleft: string;
estimatedCompletionTime: string;
status: string;
trackedDownloadStatus: string;
trackedDownloadState: string;
downloadId: string;
protocol: string;
downloadClient: string;
indexer: string;
id: number;
}
interface QueueResponse {
page: number;
pageSize: number;
sortKey: string;
sortDirection: string;
totalRecords: number;
records: QueueItem[];
}
class RadarrAPI extends ExternalAPI {
static buildRadarrUrl(radarrSettings: RadarrSettings, path?: string): string {
return `${radarrSettings.useSsl ? 'https' : 'http'}://${
radarrSettings.hostname
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}${path}`;
}
class RadarrAPI extends ServarrBase<{ movieId: number }> {
constructor({ url, apiKey }: { url: string; apiKey: string }) {
super(
url,
{
apikey: apiKey,
},
{
nodeCache: cacheManager.getCache('radarr').data,
}
);
super({ url, apiKey, cacheName: 'radarr', apiName: 'Radarr' });
}
public getMovies = async (): Promise<RadarrMovie[]> => {
@ -162,6 +105,7 @@ class RadarrAPI extends ExternalAPI {
minimumAvailability: options.minimumAvailability,
tmdbId: options.tmdbId,
year: options.year,
tags: options.tags,
rootFolderPath: options.rootFolderPath,
monitored: options.monitored,
addOptions: {
@ -206,6 +150,7 @@ class RadarrAPI extends ExternalAPI {
year: options.year,
rootFolderPath: options.rootFolderPath,
monitored: options.monitored,
tags: options.tags,
addOptions: {
searchForMovie: options.searchNow,
},
@ -238,44 +183,6 @@ class RadarrAPI extends ExternalAPI {
throw new Error('Failed to add movie to Radarr');
}
};
public getProfiles = async (): Promise<RadarrProfile[]> => {
try {
const data = await this.getRolling<RadarrProfile[]>(
`/qualityProfile`,
undefined,
3600
);
return data;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve profiles: ${e.message}`);
}
};
public getRootFolders = async (): Promise<RadarrRootFolder[]> => {
try {
const data = await this.getRolling<RadarrRootFolder[]>(
`/rootfolder`,
undefined,
3600
);
return data;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve root folders: ${e.message}`);
}
};
public getQueue = async (): Promise<QueueItem[]> => {
try {
const response = await this.axios.get<QueueResponse>(`/queue`);
return response.data.records;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`);
}
};
}
export default RadarrAPI;

@ -1,7 +1,5 @@
import cacheManager from '../lib/cache';
import { SonarrSettings } from '../lib/settings';
import logger from '../logger';
import ExternalAPI from './externalapi';
import logger from '../../logger';
import ServarrBase from './base';
interface SonarrSeason {
seasonNumber: number;
@ -49,7 +47,7 @@ export interface SonarrSeries {
titleSlug: string;
certification: string;
genres: string[];
tags: string[];
tags: number[];
added: string;
ratings: {
votes: number;
@ -65,49 +63,6 @@ export interface SonarrSeries {
};
}
interface QueueItem {
seriesId: number;
episodeId: number;
size: number;
title: string;
sizeleft: number;
timeleft: string;
estimatedCompletionTime: string;
status: string;
trackedDownloadStatus: string;
trackedDownloadState: string;
downloadId: string;
protocol: string;
downloadClient: string;
indexer: string;
id: number;
}
interface QueueResponse {
page: number;
pageSize: number;
sortKey: string;
sortDirection: string;
totalRecords: number;
records: QueueItem[];
}
interface SonarrProfile {
id: number;
name: string;
}
interface SonarrRootFolder {
id: number;
path: string;
freeSpace: number;
totalSpace: number;
unmappedFolders: {
name: string;
path: string;
}[];
}
interface AddSeriesOptions {
tvdbid: number;
title: string;
@ -116,6 +71,7 @@ interface AddSeriesOptions {
seasons: number[];
seasonFolder: boolean;
rootFolderPath: string;
tags?: number[];
seriesType: SonarrSeries['seriesType'];
monitored?: boolean;
searchNow?: boolean;
@ -126,23 +82,9 @@ export interface LanguageProfile {
name: string;
}
class SonarrAPI extends ExternalAPI {
static buildSonarrUrl(sonarrSettings: SonarrSettings, path?: string): string {
return `${sonarrSettings.useSsl ? 'https' : 'http'}://${
sonarrSettings.hostname
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}${path}`;
}
class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
constructor({ url, apiKey }: { url: string; apiKey: string }) {
super(
url,
{
apikey: apiKey,
},
{
nodeCache: cacheManager.getCache('sonarr').data,
}
);
super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
}
public async getSeries(): Promise<SonarrSeries[]> {
@ -151,7 +93,7 @@ class SonarrAPI extends ExternalAPI {
return response.data;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve series: ${e.message}`);
throw new Error(`[Sonarr] Failed to retrieve series: ${e.message}`);
}
}
@ -205,6 +147,7 @@ class SonarrAPI extends ExternalAPI {
// If the series already exists, we will simply just update it
if (series.id) {
series.tags = options.tags ?? series.tags;
series.seasons = this.buildSeasonList(options.seasons, series.seasons);
const newSeriesResponse = await this.axios.put<SonarrSeries>(
@ -249,6 +192,7 @@ class SonarrAPI extends ExternalAPI {
monitored: false,
}))
),
tags: options.tags,
seasonFolder: options.seasonFolder,
monitored: options.monitored,
rootFolderPath: options.rootFolderPath,
@ -286,46 +230,6 @@ class SonarrAPI extends ExternalAPI {
}
}
public async getProfiles(): Promise<SonarrProfile[]> {
try {
const data = await this.getRolling<SonarrProfile[]>(
'/qualityProfile',
undefined,
3600
);
return data;
} catch (e) {
logger.error('Something went wrong while retrieving Sonarr profiles.', {
label: 'Sonarr API',
message: e.message,
});
throw new Error('Failed to get profiles');
}
}
public async getRootFolders(): Promise<SonarrRootFolder[]> {
try {
const data = await this.getRolling<SonarrRootFolder[]>(
'/rootfolder',
undefined,
3600
);
return data;
} catch (e) {
logger.error(
'Something went wrong while retrieving Sonarr root folders.',
{
label: 'Sonarr API',
message: e.message,
}
);
throw new Error('Failed to get root folders');
}
}
public async getLanguageProfiles(): Promise<LanguageProfile[]> {
try {
const data = await this.getRolling<LanguageProfile[]>(
@ -356,25 +260,6 @@ class SonarrAPI extends ExternalAPI {
await this.runCommand('SeriesSearch', { seriesId });
}
private async runCommand(
commandName: string,
options: Record<string, unknown>
): Promise<void> {
try {
await this.axios.post(`/command`, {
name: commandName,
...options,
});
} catch (e) {
logger.error('Something went wrong attempting to run a Sonarr command.', {
label: 'Sonarr API',
message: e.message,
});
throw new Error('Failed to run Sonarr command.');
}
}
private buildSeasonList(
seasons: number[],
existingSeasons?: SonarrSeason[]
@ -399,16 +284,6 @@ class SonarrAPI extends ExternalAPI {
return newSeasons;
}
public getQueue = async (): Promise<QueueItem[]> => {
try {
const response = await this.axios.get<QueueResponse>(`/queue`);
return response.data.records;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`);
}
};
}
export default SonarrAPI;

@ -1,23 +1,23 @@
import {
Entity,
PrimaryGeneratedColumn,
AfterLoad,
Column,
Index,
OneToMany,
CreateDateColumn,
UpdateDateColumn,
Entity,
getRepository,
In,
AfterLoad,
Index,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { MediaRequest } from './MediaRequest';
import RadarrAPI from '../api/servarr/radarr';
import SonarrAPI from '../api/servarr/sonarr';
import { MediaStatus, MediaType } from '../constants/media';
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import { MediaRequest } from './MediaRequest';
import Season from './Season';
import { getSettings } from '../lib/settings';
import RadarrAPI from '../api/radarr';
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
import SonarrAPI from '../api/sonarr';
@Entity()
class Media {
@ -168,10 +168,7 @@ class Media {
if (server) {
this.serviceUrl = server.externalUrl
? `${server.externalUrl}/movie/${this.externalServiceSlug}`
: RadarrAPI.buildRadarrUrl(
server,
`/movie/${this.externalServiceSlug}`
);
: RadarrAPI.buildUrl(server, `/movie/${this.externalServiceSlug}`);
}
}
@ -184,7 +181,7 @@ class Media {
if (server) {
this.serviceUrl4k = server.externalUrl
? `${server.externalUrl}/movie/${this.externalServiceSlug4k}`
: RadarrAPI.buildRadarrUrl(
: RadarrAPI.buildUrl(
server,
`/movie/${this.externalServiceSlug4k}`
);
@ -202,10 +199,7 @@ class Media {
if (server) {
this.serviceUrl = server.externalUrl
? `${server.externalUrl}/series/${this.externalServiceSlug}`
: SonarrAPI.buildSonarrUrl(
server,
`/series/${this.externalServiceSlug}`
);
: SonarrAPI.buildUrl(server, `/series/${this.externalServiceSlug}`);
}
}
@ -218,7 +212,7 @@ class Media {
if (server) {
this.serviceUrl4k = server.externalUrl
? `${server.externalUrl}/series/${this.externalServiceSlug4k}`
: SonarrAPI.buildSonarrUrl(
: SonarrAPI.buildUrl(
server,
`/series/${this.externalServiceSlug4k}`
);

@ -1,28 +1,29 @@
import { isEqual } from 'lodash';
import {
Entity,
PrimaryGeneratedColumn,
ManyToOne,
AfterInsert,
AfterRemove,
AfterUpdate,
Column,
CreateDateColumn,
UpdateDateColumn,
AfterUpdate,
AfterInsert,
Entity,
getRepository,
ManyToOne,
OneToMany,
AfterRemove,
PrimaryGeneratedColumn,
RelationCount,
UpdateDateColumn,
} from 'typeorm';
import { User } from './User';
import Media from './Media';
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
import { getSettings } from '../lib/settings';
import RadarrAPI from '../api/servarr/radarr';
import SonarrAPI, { SonarrSeries } from '../api/servarr/sonarr';
import TheMovieDb from '../api/themoviedb';
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants';
import RadarrAPI from '../api/radarr';
import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media';
import notificationManager, { Notification } from '../lib/notifications';
import { getSettings } from '../lib/settings';
import logger from '../logger';
import Media from './Media';
import SeasonRequest from './SeasonRequest';
import SonarrAPI, { SonarrSeries } from '../api/sonarr';
import notificationManager, { Notification } from '../lib/notifications';
import { User } from './User';
@Entity()
export class MediaRequest {
@ -85,6 +86,37 @@ export class MediaRequest {
@Column({ nullable: true })
public languageProfileId: number;
@Column({
type: 'text',
nullable: true,
transformer: {
from: (value: string | null): number[] | null => {
if (value) {
if (value === 'none') {
return [];
}
return value.split(',').map((v) => Number(v));
}
return null;
},
to: (value: number[] | null): string | null => {
if (value) {
const finalValue = value.join(',');
// We want to keep the actual state of an "empty array" so we use
// the keyword "none" to track this.
if (!finalValue) {
return 'none';
}
return finalValue;
}
return null;
},
},
})
public tags?: number[];
constructor(init?: Partial<MediaRequest>) {
Object.assign(this, init);
}
@ -365,6 +397,7 @@ export class MediaRequest {
let rootFolder = radarrSettings.activeDirectory;
let qualityProfile = radarrSettings.activeProfileId;
let tags = radarrSettings.tags;
if (
this.rootFolder &&
@ -387,10 +420,22 @@ export class MediaRequest {
});
}
if (
this.tags &&
(radarrSettings.tags.length !== (this.tags?.length ?? 0) ||
radarrSettings.tags.every((num) => (this.tags ?? []).includes(num)))
) {
tags = this.tags;
logger.info(`Request has override tags`, {
label: 'Media Request',
tagIds: tags,
});
}
const tmdb = new TheMovieDb();
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'),
url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
});
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
@ -420,6 +465,7 @@ export class MediaRequest {
tmdbId: movie.id,
year: Number(movie.release_date.slice(0, 4)),
monitored: true,
tags,
searchNow: !radarrSettings.preventSearch,
})
.then(async (radarrMovie) => {
@ -531,7 +577,7 @@ export class MediaRequest {
const tmdb = new TheMovieDb();
const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey,
url: SonarrAPI.buildSonarrUrl(sonarrSettings, '/api/v3'),
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
});
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
@ -568,6 +614,11 @@ export class MediaRequest {
? sonarrSettings.activeAnimeLanguageProfileId
: sonarrSettings.activeLanguageProfileId;
let tags =
seriesType === 'anime'
? sonarrSettings.animeTags
: sonarrSettings.tags;
if (
this.rootFolder &&
this.rootFolder !== '' &&
@ -599,6 +650,14 @@ export class MediaRequest {
);
}
if (this.tags && !isEqual(this.tags, tags)) {
tags = this.tags;
logger.info(`Request has override tags`, {
label: 'Media Request',
tags,
});
}
// Run this asynchronously so we don't wait for it on the UI side
sonarr
.addSeries({
@ -610,6 +669,7 @@ export class MediaRequest {
seasons: this.seasons.map((season) => season.seasonNumber),
seasonFolder: sonarrSettings.enableSeasonFolders,
seriesType,
tags,
monitored: true,
searchNow: !sonarrSettings.preventSearch,
})

@ -1,5 +1,5 @@
import { RadarrProfile, RadarrRootFolder } from '../../api/radarr';
import { LanguageProfile } from '../../api/sonarr';
import { QualityProfile, RootFolder, Tag } from '../../api/servarr/base';
import { LanguageProfile } from '../../api/servarr/sonarr';
export interface ServiceCommonServer {
id: number;
@ -12,11 +12,14 @@ export interface ServiceCommonServer {
activeAnimeProfileId?: number;
activeAnimeDirectory?: string;
activeAnimeLanguageProfileId?: number;
activeTags: number[];
activeAnimeTags?: number[];
}
export interface ServiceCommonServerWithDetails {
server: ServiceCommonServer;
profiles: RadarrProfile[];
rootFolders: Partial<RadarrRootFolder>[];
profiles: QualityProfile[];
rootFolders: Partial<RootFolder>[];
languageProfiles?: LanguageProfile[];
tags: Tag[];
}

@ -1,6 +1,6 @@
import { uniqWith } from 'lodash';
import RadarrAPI from '../api/radarr';
import SonarrAPI from '../api/sonarr';
import RadarrAPI from '../api/servarr/radarr';
import SonarrAPI from '../api/servarr/sonarr';
import { MediaType } from '../constants/media';
import logger from '../logger';
import { getSettings } from './settings';
@ -73,7 +73,7 @@ class DownloadTracker {
if (server.syncEnabled) {
const radarr = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
url: RadarrAPI.buildUrl(server, '/api/v3'),
});
const queueItems = await radarr.getQueue();
@ -140,7 +140,7 @@ class DownloadTracker {
if (server.syncEnabled) {
const radarr = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
url: SonarrAPI.buildUrl(server, '/api/v3'),
});
const queueItems = await radarr.getQueue();

@ -1,5 +1,5 @@
import { uniqWith } from 'lodash';
import RadarrAPI, { RadarrMovie } from '../../../api/radarr';
import RadarrAPI, { RadarrMovie } from '../../../api/servarr/radarr';
import { getSettings, RadarrSettings } from '../../settings';
import BaseScanner, { RunnableScanner, StatusBase } from '../baseScanner';
@ -52,7 +52,7 @@ class RadarrScanner
this.radarrApi = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
url: RadarrAPI.buildUrl(server, '/api/v3'),
});
this.items = await this.radarrApi.getMovies();

@ -1,6 +1,6 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import SonarrAPI, { SonarrSeries } from '../../../api/sonarr';
import SonarrAPI, { SonarrSeries } from '../../../api/servarr/sonarr';
import Media from '../../../entity/Media';
import { getSettings, SonarrSettings } from '../../settings';
import BaseScanner, {
@ -58,7 +58,7 @@ class SonarrScanner
this.sonarrApi = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
url: SonarrAPI.buildUrl(server, '/api/v3'),
});
this.items = await this.sonarrApi.getSeries();

@ -30,7 +30,7 @@ export interface PlexSettings {
libraries: Library[];
}
interface DVRSettings {
export interface DVRSettings {
id: number;
name: string;
hostname: string;
@ -41,6 +41,7 @@ interface DVRSettings {
activeProfileId: number;
activeProfileName: string;
activeDirectory: string;
tags: number[];
is4k: boolean;
isDefault: boolean;
externalUrl?: string;
@ -58,6 +59,7 @@ export interface SonarrSettings extends DVRSettings {
activeAnimeDirectory?: string;
activeAnimeLanguageProfileId?: number;
activeLanguageProfileId?: number;
animeTags?: number[];
enableSeasonFolders: boolean;
}

@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateTagsFieldonMediaRequest1617624225464
implements MigrationInterface {
name = 'CreateTagsFieldonMediaRequest1617624225464';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId" FROM "media_request"`
);
await queryRunner.query(`DROP TABLE "media_request"`);
await queryRunner.query(
`ALTER TABLE "temporary_media_request" RENAME TO "media_request"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "media_request" RENAME TO "temporary_media_request"`
);
await queryRunner.query(
`CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId" FROM "temporary_media_request"`
);
await queryRunner.query(`DROP TABLE "temporary_media_request"`);
}
}

@ -278,6 +278,7 @@ requestRoutes.post(
serverId: req.body.serverId,
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
tags: req.body.tags,
});
await requestRepository.save(request);
@ -356,6 +357,7 @@ requestRoutes.post(
profileId: req.body.profileId,
rootFolder: req.body.rootFolder,
languageProfileId: req.body.languageProfileId,
tags: req.body.tags,
seasons: finalSeasons.map(
(sn) =>
new SeasonRequest({
@ -497,6 +499,7 @@ requestRoutes.put<{ requestId: string }>(
request.serverId = req.body.serverId;
request.profileId = req.body.profileId;
request.rootFolder = req.body.rootFolder;
request.tags = req.body.tags;
request.requestedBy = requestUser as User;
requestRepository.save(request);
@ -505,6 +508,8 @@ requestRoutes.put<{ requestId: string }>(
request.serverId = req.body.serverId;
request.profileId = req.body.profileId;
request.rootFolder = req.body.rootFolder;
request.languageProfileId = req.body.languageProfileId;
request.tags = req.body.tags;
request.requestedBy = requestUser as User;
const requestedSeasons = req.body.seasons as number[] | undefined;

@ -1,12 +1,12 @@
import { Router } from 'express';
import RadarrAPI from '../api/radarr';
import SonarrAPI from '../api/sonarr';
import RadarrAPI from '../api/servarr/radarr';
import SonarrAPI from '../api/servarr/sonarr';
import TheMovieDb from '../api/themoviedb';
import {
ServiceCommonServer,
ServiceCommonServerWithDetails,
} from '../interfaces/api/serviceInterfaces';
import { getSettings } from '../lib/settings';
import TheMovieDb from '../api/themoviedb';
import logger from '../logger';
const serviceRoutes = Router();
@ -22,6 +22,7 @@ serviceRoutes.get('/radarr', async (req, res) => {
isDefault: radarr.isDefault,
activeDirectory: radarr.activeDirectory,
activeProfileId: radarr.activeProfileId,
activeTags: radarr.tags ?? [],
})
);
@ -46,11 +47,12 @@ serviceRoutes.get<{ radarrId: string }>(
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'),
url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
});
const profiles = await radarr.getProfiles();
const rootFolders = await radarr.getRootFolders();
const tags = await radarr.getTags();
return res.status(200).json({
server: {
@ -60,6 +62,7 @@ serviceRoutes.get<{ radarrId: string }>(
isDefault: radarrSettings.isDefault,
activeDirectory: radarrSettings.activeDirectory,
activeProfileId: radarrSettings.activeProfileId,
activeTags: radarrSettings.tags,
},
profiles: profiles.map((profile) => ({
id: profile.id,
@ -71,6 +74,7 @@ serviceRoutes.get<{ radarrId: string }>(
path: folder.path,
totalSpace: folder.totalSpace,
})),
tags,
} as ServiceCommonServerWithDetails);
}
);
@ -90,6 +94,7 @@ serviceRoutes.get('/sonarr', async (req, res) => {
activeAnimeDirectory: sonarr.activeAnimeDirectory,
activeLanguageProfileId: sonarr.activeLanguageProfileId,
activeAnimeLanguageProfileId: sonarr.activeAnimeLanguageProfileId,
activeTags: [],
})
);
@ -114,13 +119,14 @@ serviceRoutes.get<{ sonarrId: string }>(
const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey,
url: SonarrAPI.buildSonarrUrl(sonarrSettings, '/api/v3'),
url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
});
try {
const profiles = await sonarr.getProfiles();
const rootFolders = await sonarr.getRootFolders();
const languageProfiles = await sonarr.getLanguageProfiles();
const tags = await sonarr.getTags();
return res.status(200).json({
server: {
@ -135,6 +141,8 @@ serviceRoutes.get<{ sonarrId: string }>(
activeLanguageProfileId: sonarrSettings.activeLanguageProfileId,
activeAnimeLanguageProfileId:
sonarrSettings.activeAnimeLanguageProfileId,
activeTags: sonarrSettings.tags,
activeAnimeTags: sonarrSettings.animeTags,
},
profiles: profiles.map((profile) => ({
id: profile.id,
@ -147,6 +155,7 @@ serviceRoutes.get<{ sonarrId: string }>(
totalSpace: folder.totalSpace,
})),
languageProfiles: languageProfiles,
tags,
} as ServiceCommonServerWithDetails);
} catch (e) {
next({ status: 500, message: e.message });

@ -1,5 +1,5 @@
import { Router } from 'express';
import RadarrAPI from '../../api/radarr';
import RadarrAPI from '../../api/servarr/radarr';
import { getSettings, RadarrSettings } from '../../lib/settings';
import logger from '../../logger';
@ -35,15 +35,20 @@ radarrRoutes.post('/', (req, res) => {
return res.status(201).json(newRadarr);
});
radarrRoutes.post('/test', async (req, res, next) => {
radarrRoutes.post<
undefined,
Record<string, unknown>,
RadarrSettings & { tagLabel?: string }
>('/test', async (req, res, next) => {
try {
const radarr = new RadarrAPI({
apiKey: req.body.apiKey,
url: RadarrAPI.buildRadarrUrl(req.body, '/api/v3'),
url: RadarrAPI.buildUrl(req.body, '/api/v3'),
});
const profiles = await radarr.getProfiles();
const folders = await radarr.getRootFolders();
const tags = await radarr.getTags();
return res.status(200).json({
profiles,
@ -51,6 +56,7 @@ radarrRoutes.post('/test', async (req, res, next) => {
id: folder.id,
path: folder.path,
})),
tags,
});
} catch (e) {
logger.error('Failed to test Radarr', {
@ -62,7 +68,9 @@ radarrRoutes.post('/test', async (req, res, next) => {
}
});
radarrRoutes.put<{ id: string }>('/:id', (req, res) => {
radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>(
'/:id',
(req, res, next) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
@ -70,9 +78,7 @@ radarrRoutes.put<{ id: string }>('/:id', (req, res) => {
);
if (radarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
return next({ status: '404', message: 'Settings instance not found' });
}
// If we are setting this as the default, clear any previous defaults for the same type first
@ -93,9 +99,10 @@ radarrRoutes.put<{ id: string }>('/:id', (req, res) => {
settings.save();
return res.status(200).json(settings.radarr[radarrIndex]);
});
}
);
radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => {
radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => {
const settings = getSettings();
const radarrSettings = settings.radarr.find(
@ -103,14 +110,12 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => {
);
if (!radarrSettings) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
return next({ status: '404', message: 'Settings instance not found' });
}
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'),
url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
});
const profiles = await radarr.getProfiles();
@ -123,7 +128,7 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => {
);
});
radarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
radarrRoutes.delete<{ id: string }>('/:id', (req, res, next) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
@ -131,9 +136,7 @@ radarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
);
if (radarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
return next({ status: '404', message: 'Settings instance not found' });
}
const removed = settings.radarr.splice(radarrIndex, 1);

@ -1,5 +1,5 @@
import { Router } from 'express';
import SonarrAPI from '../../api/sonarr';
import SonarrAPI from '../../api/servarr/sonarr';
import { getSettings, SonarrSettings } from '../../lib/settings';
import logger from '../../logger';
@ -39,12 +39,13 @@ sonarrRoutes.post('/test', async (req, res, next) => {
try {
const sonarr = new SonarrAPI({
apiKey: req.body.apiKey,
url: SonarrAPI.buildSonarrUrl(req.body, '/api/v3'),
url: SonarrAPI.buildUrl(req.body, '/api/v3'),
});
const profiles = await sonarr.getProfiles();
const folders = await sonarr.getRootFolders();
const languageProfiles = await sonarr.getLanguageProfiles();
const tags = await sonarr.getTags();
return res.status(200).json({
profiles,
@ -53,6 +54,7 @@ sonarrRoutes.post('/test', async (req, res, next) => {
path: folder.path,
})),
languageProfiles,
tags,
});
} catch (e) {
logger.error('Failed to test Sonarr', {

@ -1,7 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { Listbox, Transition } from '@headlessui/react';
import { isEqual } from 'lodash';
import dynamic from 'next/dynamic';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import type { OptionsType, OptionTypeBase } from 'react-select';
import useSWR from 'swr';
import type {
ServiceCommonServer,
@ -13,6 +16,13 @@ import globalMessages from '../../../i18n/globalMessages';
import { formatBytes } from '../../../utils/numberHelpers';
import { SmallLoadingSpinner } from '../../Common/LoadingSpinner';
type OptionType = {
value: string;
label: string;
};
const Select = dynamic(() => import('react-select'), { ssr: false });
const messages = defineMessages({
advancedoptions: 'Advanced Options',
destinationserver: 'Destination Server',
@ -23,12 +33,16 @@ const messages = defineMessages({
folder: '{path} ({space})',
requestas: 'Request As',
languageprofile: 'Language Profile',
tags: 'Tags',
selecttags: 'Select tags',
notagoptions: 'No Tags',
});
export type RequestOverrides = {
server?: number;
profile?: number;
folder?: string;
tags?: number[];
language?: number;
user?: User;
};
@ -77,6 +91,10 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
defaultOverrides?.language ?? -1
);
const [selectedTags, setSelectedTags] = useState<number[]>(
defaultOverrides?.tags ?? []
);
const {
data: serverData,
isValidating,
@ -150,6 +168,9 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
? serverData.server.activeAnimeLanguageProfileId
: serverData.server.activeLanguageProfileId)
);
const defaultTags = isAnime
? serverData.server.activeAnimeTags
: serverData.server.activeTags;
if (
defaultProfile &&
@ -174,46 +195,43 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
) {
setSelectedLanguage(defaultLanguage.id);
}
if (
defaultTags &&
!isEqual(defaultTags, selectedTags) &&
(!defaultOverrides || defaultOverrides.tags === null)
) {
setSelectedTags(defaultTags);
}
}
}, [serverData]);
useEffect(() => {
if (
defaultOverrides &&
defaultOverrides.server !== null &&
defaultOverrides.server !== undefined
) {
if (defaultOverrides && defaultOverrides.server != null) {
setSelectedServer(defaultOverrides.server);
}
if (
defaultOverrides &&
defaultOverrides.profile !== null &&
defaultOverrides.profile !== undefined
) {
if (defaultOverrides && defaultOverrides.profile != null) {
setSelectedProfile(defaultOverrides.profile);
}
if (
defaultOverrides &&
defaultOverrides.folder !== null &&
defaultOverrides.folder !== undefined
) {
if (defaultOverrides && defaultOverrides.folder != null) {
setSelectedFolder(defaultOverrides.folder);
}
if (
defaultOverrides &&
defaultOverrides.language !== null &&
defaultOverrides.language !== undefined
) {
if (defaultOverrides && defaultOverrides.language != null) {
setSelectedLanguage(defaultOverrides.language);
}
if (defaultOverrides && defaultOverrides.tags != null) {
setSelectedTags(defaultOverrides.tags);
}
}, [
defaultOverrides?.server,
defaultOverrides?.folder,
defaultOverrides?.profile,
defaultOverrides?.language,
defaultOverrides?.tags,
]);
useEffect(() => {
@ -224,6 +242,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
server: selectedServer ?? undefined,
user: selectedUser ?? undefined,
language: selectedLanguage ?? undefined,
tags: selectedTags,
});
}
}, [
@ -232,6 +251,7 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
selectedProfile,
selectedUser,
selectedLanguage,
selectedTags,
]);
if (!data && !error) {
@ -436,9 +456,43 @@ const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
</div>
</>
)}
{!!data && selectedServer !== null && (
<div className="mt-0 sm:mt-2">
<label htmlFor="tags">{intl.formatMessage(messages.tags)}</label>
<Select
name="tags"
options={(serverData?.tags ?? []).map((tag) => ({
label: tag.label,
value: tag.id,
}))}
isMulti
placeholder={intl.formatMessage(messages.selecttags)}
className="react-select-container react-select-container-dark"
classNamePrefix="react-select"
value={selectedTags.map((tagId) => {
const foundTag = serverData?.tags.find(
(tag) => tag.id === tagId
);
return {
value: foundTag?.id,
label: foundTag?.label,
};
})}
onChange={(
value: OptionTypeBase | OptionsType<OptionType> | null
) => {
if (!Array.isArray(value)) {
return;
}
setSelectedTags(value?.map((option) => option.value));
}}
noOptionsMessage={() => intl.formatMessage(messages.notagoptions)}
/>
</div>
)}
{hasPermission([Permission.MANAGE_REQUESTS, Permission.MANAGE_USERS]) &&
selectedUser && (
<div className="first:mt-0 sm:mt-4">
<div className="mt-2 first:mt-0">
<Listbox
as="div"
value={selectedUser}

@ -84,6 +84,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
profileId: requestOverrides.profile,
rootFolder: requestOverrides.folder,
userId: requestOverrides.user?.id,
tags: requestOverrides.tags,
};
}
const response = await axios.post<MediaRequest>('/api/v1/request', {
@ -173,6 +174,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
profileId: requestOverrides?.profile,
rootFolder: requestOverrides?.folder,
userId: requestOverrides?.user?.id,
tags: requestOverrides?.tags,
});
addToast(
@ -254,6 +256,7 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
folder: editRequest.rootFolder,
profile: editRequest.profileId,
server: editRequest.serverId,
tags: editRequest.tags,
}
: undefined
}

@ -1,7 +1,7 @@
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { SonarrSeries } from '../../../../server/api/sonarr';
import { SonarrSeries } from '../../../../server/api/servarr/sonarr';
import globalMessages from '../../../i18n/globalMessages';
import Alert from '../../Common/Alert';
import { SmallLoadingSpinner } from '../../Common/LoadingSpinner';

@ -107,6 +107,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
rootFolder: requestOverrides?.folder,
languageProfileId: requestOverrides?.language,
userId: requestOverrides?.user?.id,
tags: requestOverrides?.tags,
seasons: selectedSeasons,
});
} else {
@ -170,6 +171,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
rootFolder: requestOverrides.folder,
languageProfileId: requestOverrides.language,
userId: requestOverrides?.user?.id,
tags: requestOverrides.tags,
};
}
const response = await axios.post<MediaRequest>('/api/v1/request', {
@ -669,6 +671,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
profile: editRequest.profileId,
server: editRequest.serverId,
language: editRequest.languageProfileId,
tags: editRequest.tags,
}
: undefined
}

@ -1,7 +1,9 @@
import axios from 'axios';
import { Field, Formik } from 'formik';
import dynamic from 'next/dynamic';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import type { OptionsType, OptionTypeBase } from 'react-select';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
import type { RadarrSettings } from '../../../../server/lib/settings';
@ -9,9 +11,18 @@ import globalMessages from '../../../i18n/globalMessages';
import Modal from '../../Common/Modal';
import Transition from '../../Transition';
type OptionType = {
value: string;
label: string;
};
const Select = dynamic(() => import('react-select'), { ssr: false });
const messages = defineMessages({
createradarr: 'Add New Radarr Server',
create4kradarr: 'Add New 4K Radarr Server',
editradarr: 'Edit Radarr Server',
edit4kradarr: 'Edit 4K Radarr Server',
validationNameRequired: 'You must provide a server name',
validationHostnameRequired: 'You must provide a hostname or IP address',
validationPortRequired: 'You must provide a valid port number',
@ -24,6 +35,7 @@ const messages = defineMessages({
toastRadarrTestFailure: 'Failed to connect to Radarr.',
add: 'Add Server',
defaultserver: 'Default Server',
default4kserver: 'Default 4K Server',
servername: 'Server Name',
servernamePlaceholder: 'A Radarr Server',
hostname: 'Hostname or IP Address',
@ -47,11 +59,15 @@ const messages = defineMessages({
testFirstQualityProfiles: 'Test connection to load quality profiles',
loadingrootfolders: 'Loading root folders…',
testFirstRootFolders: 'Test connection to load root folders',
testFirstTags: 'Test connection to load tags',
tags: 'Tags',
preventSearch: 'Disable Auto-Search',
validationApplicationUrl: 'You must provide a valid URL',
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash',
validationBaseUrlTrailingSlash: 'Base URL must not end in a trailing slash',
notagoptions: 'No Tags',
selecttags: 'Select tags',
});
interface TestResponse {
@ -63,6 +79,10 @@ interface TestResponse {
id: number;
path: string;
}[];
tags: {
id: number;
label: string;
}[];
}
interface RadarrModalProps {
@ -84,6 +104,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
const [testResponse, setTestResponse] = useState<TestResponse>({
profiles: [],
rootFolders: [],
tags: [],
});
const RadarrSettingsSchema = Yup.object().shape({
name: Yup.string().required(
@ -92,7 +113,6 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
hostname: Yup.string()
.required(intl.formatMessage(messages.validationHostnameRequired))
.matches(
// eslint-disable-next-line
/^(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
intl.formatMessage(messages.validationHostnameRequired)
),
@ -194,7 +214,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
initialLoad.current = true;
}
},
[addToast]
[addToast, intl]
);
useEffect(() => {
@ -231,6 +251,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
activeProfileId: radarr?.activeProfileId,
rootFolder: radarr?.activeDirectory,
minimumAvailability: radarr?.minimumAvailability ?? 'released',
tags: radarr?.tags ?? [],
isDefault: radarr?.isDefault ?? false,
is4k: radarr?.is4k ?? false,
externalUrl: radarr?.externalUrl,
@ -256,6 +277,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
activeDirectory: values.rootFolder,
is4k: values.is4k,
minimumAvailability: values.minimumAvailability,
tags: values.tags,
isDefault: values.isDefault,
externalUrl: values.externalUrl,
syncEnabled: values.syncEnabled,
@ -324,14 +346,24 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
onOk={() => handleSubmit()}
title={
!radarr
? intl.formatMessage(messages.createradarr)
: intl.formatMessage(messages.editradarr)
? intl.formatMessage(
values.is4k
? messages.create4kradarr
: messages.createradarr
)
: intl.formatMessage(
values.is4k ? messages.edit4kradarr : messages.editradarr
)
}
>
<div className="mb-6">
<div className="form-row">
<label htmlFor="isDefault" className="checkbox-label">
{intl.formatMessage(messages.defaultserver)}
{intl.formatMessage(
values.is4k
? messages.default4kserver
: messages.defaultserver
)}
</label>
<div className="form-input">
<Field type="checkbox" id="isDefault" name="isDefault" />
@ -584,6 +616,55 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
)}
</div>
</div>
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}
</label>
<div className="form-input">
<Select
options={
isValidated
? testResponse.tags.map((tag) => ({
label: tag.label,
value: tag.id,
}))
: []
}
isMulti
isDisabled={!isValidated}
placeholder={
!isValidated
? intl.formatMessage(messages.testFirstTags)
: intl.formatMessage(messages.selecttags)
}
className="react-select-container"
classNamePrefix="react-select"
value={values.tags.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === tagId
);
return {
value: foundTag?.id,
label: foundTag?.label,
};
})}
onChange={(
value: OptionTypeBase | OptionsType<OptionType> | null
) => {
if (!Array.isArray(value)) {
return;
}
setFieldValue(
'tags',
value?.map((option) => option.value)
);
}}
noOptionsMessage={() =>
intl.formatMessage(messages.notagoptions)
}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="externalUrl" className="text-label">
{intl.formatMessage(messages.externalUrl)}

@ -1,7 +1,9 @@
import axios from 'axios';
import { Field, Formik } from 'formik';
import dynamic from 'next/dynamic';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import type { OptionsType, OptionTypeBase } from 'react-select';
import { useToasts } from 'react-toast-notifications';
import * as Yup from 'yup';
import type { SonarrSettings } from '../../../../server/lib/settings';
@ -9,9 +11,18 @@ import globalMessages from '../../../i18n/globalMessages';
import Modal from '../../Common/Modal';
import Transition from '../../Transition';
type OptionType = {
value: string;
label: string;
};
const Select = dynamic(() => import('react-select'), { ssr: false });
const messages = defineMessages({
createsonarr: 'Add New Sonarr Server',
create4ksonarr: 'Add New 4K Sonarr Server',
editsonarr: 'Edit Sonarr Server',
edit4ksonarr: 'Edit 4K Sonarr Server',
validationNameRequired: 'You must provide a server name',
validationHostnameRequired: 'You must provide a hostname or IP address',
validationPortRequired: 'You must provide a valid port number',
@ -23,6 +34,7 @@ const messages = defineMessages({
toastSonarrTestFailure: 'Failed to connect to Sonarr.',
add: 'Add Server',
defaultserver: 'Default Server',
default4kserver: 'Default 4K Server',
servername: 'Server Name',
servernamePlaceholder: 'A Sonarr Server',
hostname: 'Hostname or IP Address',
@ -49,6 +61,8 @@ const messages = defineMessages({
testFirstRootFolders: 'Test connection to load root folders',
loadinglanguageprofiles: 'Loading language profiles…',
testFirstLanguageProfiles: 'Test connection to load language profiles',
loadingTags: 'Loading tags…',
testFirstTags: 'Test connection to load tags',
syncEnabled: 'Enable Scan',
externalUrl: 'External URL',
externalUrlPlaceholder: 'External URL pointing to your Sonarr server',
@ -57,6 +71,10 @@ const messages = defineMessages({
validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash',
validationBaseUrlLeadingSlash: 'Base URL must have a leading slash',
validationBaseUrlTrailingSlash: 'Base URL must not end in a trailing slash',
tags: 'Tags',
animeTags: 'Tags',
notagoptions: 'No Tags',
selecttags: 'Select tags',
});
interface TestResponse {
@ -72,6 +90,10 @@ interface TestResponse {
id: number;
name: string;
}[];
tags: {
id: number;
label: string;
}[];
}
interface SonarrModalProps {
@ -94,6 +116,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
profiles: [],
rootFolders: [],
languageProfiles: [],
tags: [],
});
const SonarrSettingsSchema = Yup.object().shape({
name: Yup.string().required(
@ -102,7 +125,6 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
hostname: Yup.string()
.required(intl.formatMessage(messages.validationHostnameRequired))
.matches(
// eslint-disable-next-line
/^(([a-z]|\d|_|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])$/i,
intl.formatMessage(messages.validationHostnameRequired)
),
@ -204,7 +226,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
initialLoad.current = true;
}
},
[addToast]
[addToast, intl]
);
useEffect(() => {
@ -244,6 +266,8 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
activeAnimeProfileId: sonarr?.activeAnimeProfileId,
activeAnimeLanguageProfileId: sonarr?.activeAnimeLanguageProfileId,
activeAnimeRootFolder: sonarr?.activeAnimeDirectory,
tags: sonarr?.tags ?? [],
animeTags: sonarr?.animeTags ?? [],
isDefault: sonarr?.isDefault ?? false,
is4k: sonarr?.is4k ?? false,
enableSeasonFolders: sonarr?.enableSeasonFolders ?? false,
@ -282,6 +306,8 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
: undefined,
activeAnimeProfileName: animeProfileName ?? undefined,
activeAnimeDirectory: values.activeAnimeRootFolder,
tags: values.tags,
animeTags: values.animeTags,
is4k: values.is4k,
isDefault: values.isDefault,
enableSeasonFolders: values.enableSeasonFolders,
@ -352,14 +378,24 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
onOk={() => handleSubmit()}
title={
!sonarr
? intl.formatMessage(messages.createsonarr)
: intl.formatMessage(messages.editsonarr)
? intl.formatMessage(
values.is4k
? messages.create4ksonarr
: messages.createsonarr
)
: intl.formatMessage(
values.is4k ? messages.edit4ksonarr : messages.editsonarr
)
}
>
<div className="mb-6">
<div className="form-row">
<label htmlFor="isDefault" className="checkbox-label">
{intl.formatMessage(messages.defaultserver)}
{intl.formatMessage(
values.is4k
? messages.default4kserver
: messages.defaultserver
)}
</label>
<div className="form-input">
<Field type="checkbox" id="isDefault" name="isDefault" />
@ -634,6 +670,62 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)}
</div>
</div>
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.tags)}
</label>
<div className="form-input">
<Select
options={
isValidated
? testResponse.tags.map((tag) => ({
label: tag.label,
value: tag.id,
}))
: []
}
isMulti
isDisabled={!isValidated}
placeholder={
!isValidated
? intl.formatMessage(messages.testFirstTags)
: isTesting
? intl.formatMessage(messages.loadingTags)
: intl.formatMessage(messages.selecttags)
}
isLoading={isTesting}
className="react-select-container"
classNamePrefix="react-select"
value={
isTesting
? []
: values.tags.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === tagId
);
return {
value: foundTag?.id,
label: foundTag?.label,
};
})
}
onChange={(
value: OptionTypeBase | OptionsType<OptionType> | null
) => {
if (!Array.isArray(value)) {
return;
}
setFieldValue(
'tags',
value?.map((option) => option.value)
);
}}
noOptionsMessage={() =>
intl.formatMessage(messages.notagoptions)
}
/>
</div>
</div>
<div className="form-row">
<label htmlFor="activeAnimeProfileId" className="text-label">
{intl.formatMessage(messages.animequalityprofile)}
@ -757,6 +849,62 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
)}
</div>
</div>
<div className="form-row">
<label htmlFor="tags" className="text-label">
{intl.formatMessage(messages.animeTags)}
</label>
<div className="form-input">
<Select
options={
isValidated
? testResponse.tags.map((tag) => ({
label: tag.label,
value: tag.id,
}))
: []
}
isMulti
isDisabled={!isValidated}
placeholder={
!isValidated
? intl.formatMessage(messages.testFirstTags)
: isTesting
? intl.formatMessage(messages.loadingTags)
: intl.formatMessage(messages.selecttags)
}
isLoading={isTesting}
className="react-select-container"
classNamePrefix="react-select"
value={
isTesting
? []
: values.animeTags.map((tagId) => {
const foundTag = testResponse.tags.find(
(tag) => tag.id === tagId
);
return {
value: foundTag?.id,
label: foundTag?.label,
};
})
}
onChange={(
value: OptionTypeBase | OptionsType<OptionType> | null
) => {
if (!Array.isArray(value)) {
return;
}
setFieldValue(
'animeTags',
value?.map((option) => option.value)
);
}}
noOptionsMessage={() =>
intl.formatMessage(messages.notagoptions)
}
/>
</div>
</div>
<div className="form-row">
<label
htmlFor="enableSeasonFolders"

@ -176,9 +176,12 @@
"components.RequestModal.AdvancedRequester.destinationserver": "Destination Server",
"components.RequestModal.AdvancedRequester.folder": "{path} ({space})",
"components.RequestModal.AdvancedRequester.languageprofile": "Language Profile",
"components.RequestModal.AdvancedRequester.notagoptions": "No Tags",
"components.RequestModal.AdvancedRequester.qualityprofile": "Quality Profile",
"components.RequestModal.AdvancedRequester.requestas": "Request As",
"components.RequestModal.AdvancedRequester.rootfolder": "Root Folder",
"components.RequestModal.AdvancedRequester.selecttags": "Select tags",
"components.RequestModal.AdvancedRequester.tags": "Tags",
"components.RequestModal.QuotaDisplay.allowedRequests": "You are allowed to request <strong>{limit}</strong> {type} every <strong>{days}</strong> days.",
"components.RequestModal.QuotaDisplay.allowedRequestsUser": "This user is allowed to request <strong>{limit}</strong> {type} every <strong>{days}</strong> days.",
"components.RequestModal.QuotaDisplay.movie": "movie",
@ -320,8 +323,11 @@
"components.Settings.RadarrModal.apiKeyPlaceholder": "Your Radarr API key",
"components.Settings.RadarrModal.baseUrl": "Base URL",
"components.Settings.RadarrModal.baseUrlPlaceholder": "Example: /radarr",
"components.Settings.RadarrModal.create4kradarr": "Add New 4K Radarr Server",
"components.Settings.RadarrModal.createradarr": "Add New Radarr Server",
"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.externalUrl": "External URL",
"components.Settings.RadarrModal.externalUrlPlaceholder": "External URL pointing to your Radarr server",
@ -329,6 +335,7 @@
"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.port": "Port",
"components.Settings.RadarrModal.preventSearch": "Disable Auto-Search",
"components.Settings.RadarrModal.qualityprofile": "Quality Profile",
@ -336,13 +343,16 @@
"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.servernamePlaceholder": "A Radarr Server",
"components.Settings.RadarrModal.ssl": "Enable SSL",
"components.Settings.RadarrModal.syncEnabled": "Enable Scan",
"components.Settings.RadarrModal.tags": "Tags",
"components.Settings.RadarrModal.testFirstQualityProfiles": "Test connection to load quality profiles",
"components.Settings.RadarrModal.testFirstRootFolders": "Test connection to load root folders",
"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.validationApiKeyRequired": "You must provide an API key",
@ -433,6 +443,7 @@
"components.Settings.SettingsUsers.userSettingsDescription": "Configure global and default user settings.",
"components.Settings.SettingsUsers.users": "Users",
"components.Settings.SonarrModal.add": "Add Server",
"components.Settings.SonarrModal.animeTags": "Tags",
"components.Settings.SonarrModal.animelanguageprofile": "Anime Language Profile",
"components.Settings.SonarrModal.animequalityprofile": "Anime Quality Profile",
"components.Settings.SonarrModal.animerootfolder": "Anime Root Folder",
@ -440,16 +451,21 @@
"components.Settings.SonarrModal.apiKeyPlaceholder": "Your Sonarr API key",
"components.Settings.SonarrModal.baseUrl": "Base URL",
"components.Settings.SonarrModal.baseUrlPlaceholder": "Example: /sonarr",
"components.Settings.SonarrModal.create4ksonarr": "Add New 4K Sonarr Server",
"components.Settings.SonarrModal.createsonarr": "Add New Sonarr Server",
"components.Settings.SonarrModal.default4kserver": "Default 4K Server",
"components.Settings.SonarrModal.defaultserver": "Default Server",
"components.Settings.SonarrModal.edit4ksonarr": "Edit 4K Sonarr Server",
"components.Settings.SonarrModal.editsonarr": "Edit Sonarr Server",
"components.Settings.SonarrModal.externalUrl": "External URL",
"components.Settings.SonarrModal.externalUrlPlaceholder": "External URL pointing to your Sonarr server",
"components.Settings.SonarrModal.hostname": "Hostname or IP Address",
"components.Settings.SonarrModal.languageprofile": "Language Profile",
"components.Settings.SonarrModal.loadingTags": "Loading tags…",
"components.Settings.SonarrModal.loadinglanguageprofiles": "Loading language profiles…",
"components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…",
"components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…",
"components.Settings.SonarrModal.notagoptions": "No Tags",
"components.Settings.SonarrModal.port": "Port",
"components.Settings.SonarrModal.preventSearch": "Disable Auto-Search",
"components.Settings.SonarrModal.qualityprofile": "Quality Profile",
@ -458,14 +474,17 @@
"components.Settings.SonarrModal.selectLanguageProfile": "Select language profile",
"components.Settings.SonarrModal.selectQualityProfile": "Select quality profile",
"components.Settings.SonarrModal.selectRootFolder": "Select root folder",
"components.Settings.SonarrModal.selecttags": "Select tags",
"components.Settings.SonarrModal.server4k": "4K Server",
"components.Settings.SonarrModal.servername": "Server Name",
"components.Settings.SonarrModal.servernamePlaceholder": "A Sonarr Server",
"components.Settings.SonarrModal.ssl": "Enable SSL",
"components.Settings.SonarrModal.syncEnabled": "Enable Scan",
"components.Settings.SonarrModal.tags": "Tags",
"components.Settings.SonarrModal.testFirstLanguageProfiles": "Test connection to load language profiles",
"components.Settings.SonarrModal.testFirstQualityProfiles": "Test connection to load quality profiles",
"components.Settings.SonarrModal.testFirstRootFolders": "Test connection to load root folders",
"components.Settings.SonarrModal.testFirstTags": "Test connection to load tags",
"components.Settings.SonarrModal.toastSonarrTestFailure": "Failed to connect to Sonarr.",
"components.Settings.SonarrModal.toastSonarrTestSuccess": "Sonarr connection established successfully!",
"components.Settings.SonarrModal.validationApiKeyRequired": "You must provide an API key",

@ -338,18 +338,34 @@ input[type='search']::-webkit-search-cancel-button {
@apply text-white bg-gray-700 border border-gray-500 rounded-md hover:border-gray-500;
}
.react-select-container-dark .react-select__control {
@apply bg-gray-800 border border-gray-700;
}
.react-select-container .react-select__control--is-focused {
@apply text-white bg-gray-700 border border-gray-500 rounded-md shadow;
}
.react-select-container-dark .react-select__control--is-focused {
@apply bg-gray-800 border-gray-600;
}
.react-select-container .react-select__menu {
@apply text-gray-300 bg-gray-700;
}
.react-select-container-dark .react-select__menu {
@apply bg-gray-800;
}
.react-select-container .react-select__option--is-focused {
@apply text-white bg-gray-600;
}
.react-select-container-dark .react-select__option--is-focused {
@apply bg-gray-700;
}
.react-select-container .react-select__indicator-separator {
@apply bg-gray-500;
}

Loading…
Cancel
Save