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 - is4k
- enableSeasonFolders - enableSeasonFolders
- isDefault - isDefault
ServarrTag:
type: object
properties:
id:
type: number
example: 1
label:
type: string
example: A Label
PublicSettings: PublicSettings:
type: object type: object
properties: 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 logger from '../../logger';
import { RadarrSettings } from '../lib/settings'; import ServarrBase from './base';
import logger from '../logger';
import ExternalAPI from './externalapi';
interface RadarrMovieOptions { interface RadarrMovieOptions {
title: string; title: string;
qualityProfileId: number; qualityProfileId: number;
minimumAvailability: string; minimumAvailability: string;
tags: number[];
profileId: number; profileId: number;
year: number; year: number;
rootFolderPath: string; rootFolderPath: string;
@ -32,65 +31,9 @@ export interface RadarrMovie {
hasFile: boolean; hasFile: boolean;
} }
export interface RadarrRootFolder { class RadarrAPI extends ServarrBase<{ movieId: number }> {
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}`;
}
constructor({ url, apiKey }: { url: string; apiKey: string }) { constructor({ url, apiKey }: { url: string; apiKey: string }) {
super( super({ url, apiKey, cacheName: 'radarr', apiName: 'Radarr' });
url,
{
apikey: apiKey,
},
{
nodeCache: cacheManager.getCache('radarr').data,
}
);
} }
public getMovies = async (): Promise<RadarrMovie[]> => { public getMovies = async (): Promise<RadarrMovie[]> => {
@ -162,6 +105,7 @@ class RadarrAPI extends ExternalAPI {
minimumAvailability: options.minimumAvailability, minimumAvailability: options.minimumAvailability,
tmdbId: options.tmdbId, tmdbId: options.tmdbId,
year: options.year, year: options.year,
tags: options.tags,
rootFolderPath: options.rootFolderPath, rootFolderPath: options.rootFolderPath,
monitored: options.monitored, monitored: options.monitored,
addOptions: { addOptions: {
@ -206,6 +150,7 @@ class RadarrAPI extends ExternalAPI {
year: options.year, year: options.year,
rootFolderPath: options.rootFolderPath, rootFolderPath: options.rootFolderPath,
monitored: options.monitored, monitored: options.monitored,
tags: options.tags,
addOptions: { addOptions: {
searchForMovie: options.searchNow, searchForMovie: options.searchNow,
}, },
@ -238,44 +183,6 @@ class RadarrAPI extends ExternalAPI {
throw new Error('Failed to add movie to Radarr'); 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; export default RadarrAPI;

@ -1,7 +1,5 @@
import cacheManager from '../lib/cache'; import logger from '../../logger';
import { SonarrSettings } from '../lib/settings'; import ServarrBase from './base';
import logger from '../logger';
import ExternalAPI from './externalapi';
interface SonarrSeason { interface SonarrSeason {
seasonNumber: number; seasonNumber: number;
@ -49,7 +47,7 @@ export interface SonarrSeries {
titleSlug: string; titleSlug: string;
certification: string; certification: string;
genres: string[]; genres: string[];
tags: string[]; tags: number[];
added: string; added: string;
ratings: { ratings: {
votes: number; 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 { interface AddSeriesOptions {
tvdbid: number; tvdbid: number;
title: string; title: string;
@ -116,6 +71,7 @@ interface AddSeriesOptions {
seasons: number[]; seasons: number[];
seasonFolder: boolean; seasonFolder: boolean;
rootFolderPath: string; rootFolderPath: string;
tags?: number[];
seriesType: SonarrSeries['seriesType']; seriesType: SonarrSeries['seriesType'];
monitored?: boolean; monitored?: boolean;
searchNow?: boolean; searchNow?: boolean;
@ -126,23 +82,9 @@ export interface LanguageProfile {
name: string; name: string;
} }
class SonarrAPI extends ExternalAPI { class SonarrAPI extends ServarrBase<{ seriesId: number; episodeId: number }> {
static buildSonarrUrl(sonarrSettings: SonarrSettings, path?: string): string {
return `${sonarrSettings.useSsl ? 'https' : 'http'}://${
sonarrSettings.hostname
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}${path}`;
}
constructor({ url, apiKey }: { url: string; apiKey: string }) { constructor({ url, apiKey }: { url: string; apiKey: string }) {
super( super({ url, apiKey, apiName: 'Sonarr', cacheName: 'sonarr' });
url,
{
apikey: apiKey,
},
{
nodeCache: cacheManager.getCache('sonarr').data,
}
);
} }
public async getSeries(): Promise<SonarrSeries[]> { public async getSeries(): Promise<SonarrSeries[]> {
@ -151,7 +93,7 @@ class SonarrAPI extends ExternalAPI {
return response.data; return response.data;
} catch (e) { } 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 the series already exists, we will simply just update it
if (series.id) { if (series.id) {
series.tags = options.tags ?? series.tags;
series.seasons = this.buildSeasonList(options.seasons, series.seasons); series.seasons = this.buildSeasonList(options.seasons, series.seasons);
const newSeriesResponse = await this.axios.put<SonarrSeries>( const newSeriesResponse = await this.axios.put<SonarrSeries>(
@ -249,6 +192,7 @@ class SonarrAPI extends ExternalAPI {
monitored: false, monitored: false,
})) }))
), ),
tags: options.tags,
seasonFolder: options.seasonFolder, seasonFolder: options.seasonFolder,
monitored: options.monitored, monitored: options.monitored,
rootFolderPath: options.rootFolderPath, 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[]> { public async getLanguageProfiles(): Promise<LanguageProfile[]> {
try { try {
const data = await this.getRolling<LanguageProfile[]>( const data = await this.getRolling<LanguageProfile[]>(
@ -356,25 +260,6 @@ class SonarrAPI extends ExternalAPI {
await this.runCommand('SeriesSearch', { seriesId }); 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( private buildSeasonList(
seasons: number[], seasons: number[],
existingSeasons?: SonarrSeason[] existingSeasons?: SonarrSeason[]
@ -399,16 +284,6 @@ class SonarrAPI extends ExternalAPI {
return newSeasons; 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; export default SonarrAPI;

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

@ -1,28 +1,29 @@
import { isEqual } from 'lodash';
import { import {
Entity, AfterInsert,
PrimaryGeneratedColumn, AfterRemove,
ManyToOne, AfterUpdate,
Column, Column,
CreateDateColumn, CreateDateColumn,
UpdateDateColumn, Entity,
AfterUpdate,
AfterInsert,
getRepository, getRepository,
ManyToOne,
OneToMany, OneToMany,
AfterRemove, PrimaryGeneratedColumn,
RelationCount, RelationCount,
UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { User } from './User'; import RadarrAPI from '../api/servarr/radarr';
import Media from './Media'; import SonarrAPI, { SonarrSeries } from '../api/servarr/sonarr';
import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media';
import { getSettings } from '../lib/settings';
import TheMovieDb from '../api/themoviedb'; import TheMovieDb from '../api/themoviedb';
import { ANIME_KEYWORD_ID } from '../api/themoviedb/constants'; 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 logger from '../logger';
import Media from './Media';
import SeasonRequest from './SeasonRequest'; import SeasonRequest from './SeasonRequest';
import SonarrAPI, { SonarrSeries } from '../api/sonarr'; import { User } from './User';
import notificationManager, { Notification } from '../lib/notifications';
@Entity() @Entity()
export class MediaRequest { export class MediaRequest {
@ -85,6 +86,37 @@ export class MediaRequest {
@Column({ nullable: true }) @Column({ nullable: true })
public languageProfileId: number; 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>) { constructor(init?: Partial<MediaRequest>) {
Object.assign(this, init); Object.assign(this, init);
} }
@ -365,6 +397,7 @@ export class MediaRequest {
let rootFolder = radarrSettings.activeDirectory; let rootFolder = radarrSettings.activeDirectory;
let qualityProfile = radarrSettings.activeProfileId; let qualityProfile = radarrSettings.activeProfileId;
let tags = radarrSettings.tags;
if ( if (
this.rootFolder && 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 tmdb = new TheMovieDb();
const radarr = new RadarrAPI({ const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey, apiKey: radarrSettings.apiKey,
url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'), url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
}); });
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
@ -420,6 +465,7 @@ export class MediaRequest {
tmdbId: movie.id, tmdbId: movie.id,
year: Number(movie.release_date.slice(0, 4)), year: Number(movie.release_date.slice(0, 4)),
monitored: true, monitored: true,
tags,
searchNow: !radarrSettings.preventSearch, searchNow: !radarrSettings.preventSearch,
}) })
.then(async (radarrMovie) => { .then(async (radarrMovie) => {
@ -531,7 +577,7 @@ export class MediaRequest {
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();
const sonarr = new SonarrAPI({ const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey, apiKey: sonarrSettings.apiKey,
url: SonarrAPI.buildSonarrUrl(sonarrSettings, '/api/v3'), url: SonarrAPI.buildUrl(sonarrSettings, '/api/v3'),
}); });
const series = await tmdb.getTvShow({ tvId: media.tmdbId }); const series = await tmdb.getTvShow({ tvId: media.tmdbId });
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
@ -568,6 +614,11 @@ export class MediaRequest {
? sonarrSettings.activeAnimeLanguageProfileId ? sonarrSettings.activeAnimeLanguageProfileId
: sonarrSettings.activeLanguageProfileId; : sonarrSettings.activeLanguageProfileId;
let tags =
seriesType === 'anime'
? sonarrSettings.animeTags
: sonarrSettings.tags;
if ( if (
this.rootFolder && this.rootFolder &&
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 // Run this asynchronously so we don't wait for it on the UI side
sonarr sonarr
.addSeries({ .addSeries({
@ -610,6 +669,7 @@ export class MediaRequest {
seasons: this.seasons.map((season) => season.seasonNumber), seasons: this.seasons.map((season) => season.seasonNumber),
seasonFolder: sonarrSettings.enableSeasonFolders, seasonFolder: sonarrSettings.enableSeasonFolders,
seriesType, seriesType,
tags,
monitored: true, monitored: true,
searchNow: !sonarrSettings.preventSearch, searchNow: !sonarrSettings.preventSearch,
}) })

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

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

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

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

@ -30,7 +30,7 @@ export interface PlexSettings {
libraries: Library[]; libraries: Library[];
} }
interface DVRSettings { export interface DVRSettings {
id: number; id: number;
name: string; name: string;
hostname: string; hostname: string;
@ -41,6 +41,7 @@ interface DVRSettings {
activeProfileId: number; activeProfileId: number;
activeProfileName: string; activeProfileName: string;
activeDirectory: string; activeDirectory: string;
tags: number[];
is4k: boolean; is4k: boolean;
isDefault: boolean; isDefault: boolean;
externalUrl?: string; externalUrl?: string;
@ -58,6 +59,7 @@ export interface SonarrSettings extends DVRSettings {
activeAnimeDirectory?: string; activeAnimeDirectory?: string;
activeAnimeLanguageProfileId?: number; activeAnimeLanguageProfileId?: number;
activeLanguageProfileId?: number; activeLanguageProfileId?: number;
animeTags?: number[];
enableSeasonFolders: boolean; 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, serverId: req.body.serverId,
profileId: req.body.profileId, profileId: req.body.profileId,
rootFolder: req.body.rootFolder, rootFolder: req.body.rootFolder,
tags: req.body.tags,
}); });
await requestRepository.save(request); await requestRepository.save(request);
@ -356,6 +357,7 @@ requestRoutes.post(
profileId: req.body.profileId, profileId: req.body.profileId,
rootFolder: req.body.rootFolder, rootFolder: req.body.rootFolder,
languageProfileId: req.body.languageProfileId, languageProfileId: req.body.languageProfileId,
tags: req.body.tags,
seasons: finalSeasons.map( seasons: finalSeasons.map(
(sn) => (sn) =>
new SeasonRequest({ new SeasonRequest({
@ -497,6 +499,7 @@ requestRoutes.put<{ requestId: string }>(
request.serverId = req.body.serverId; request.serverId = req.body.serverId;
request.profileId = req.body.profileId; request.profileId = req.body.profileId;
request.rootFolder = req.body.rootFolder; request.rootFolder = req.body.rootFolder;
request.tags = req.body.tags;
request.requestedBy = requestUser as User; request.requestedBy = requestUser as User;
requestRepository.save(request); requestRepository.save(request);
@ -505,6 +508,8 @@ requestRoutes.put<{ requestId: string }>(
request.serverId = req.body.serverId; request.serverId = req.body.serverId;
request.profileId = req.body.profileId; request.profileId = req.body.profileId;
request.rootFolder = req.body.rootFolder; request.rootFolder = req.body.rootFolder;
request.languageProfileId = req.body.languageProfileId;
request.tags = req.body.tags;
request.requestedBy = requestUser as User; request.requestedBy = requestUser as User;
const requestedSeasons = req.body.seasons as number[] | undefined; const requestedSeasons = req.body.seasons as number[] | undefined;

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

@ -1,5 +1,5 @@
import { Router } from 'express'; import { Router } from 'express';
import RadarrAPI from '../../api/radarr'; import RadarrAPI from '../../api/servarr/radarr';
import { getSettings, RadarrSettings } from '../../lib/settings'; import { getSettings, RadarrSettings } from '../../lib/settings';
import logger from '../../logger'; import logger from '../../logger';
@ -35,15 +35,20 @@ radarrRoutes.post('/', (req, res) => {
return res.status(201).json(newRadarr); 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 { try {
const radarr = new RadarrAPI({ const radarr = new RadarrAPI({
apiKey: req.body.apiKey, apiKey: req.body.apiKey,
url: RadarrAPI.buildRadarrUrl(req.body, '/api/v3'), url: RadarrAPI.buildUrl(req.body, '/api/v3'),
}); });
const profiles = await radarr.getProfiles(); const profiles = await radarr.getProfiles();
const folders = await radarr.getRootFolders(); const folders = await radarr.getRootFolders();
const tags = await radarr.getTags();
return res.status(200).json({ return res.status(200).json({
profiles, profiles,
@ -51,6 +56,7 @@ radarrRoutes.post('/test', async (req, res, next) => {
id: folder.id, id: folder.id,
path: folder.path, path: folder.path,
})), })),
tags,
}); });
} catch (e) { } catch (e) {
logger.error('Failed to test Radarr', { logger.error('Failed to test Radarr', {
@ -62,40 +68,41 @@ radarrRoutes.post('/test', async (req, res, next) => {
} }
}); });
radarrRoutes.put<{ id: string }>('/:id', (req, res) => { radarrRoutes.put<{ id: string }, RadarrSettings, RadarrSettings>(
const settings = getSettings(); '/:id',
(req, res, next) => {
const radarrIndex = settings.radarr.findIndex( const settings = getSettings();
(r) => r.id === Number(req.params.id)
); const radarrIndex = settings.radarr.findIndex(
(r) => r.id === Number(req.params.id)
if (radarrIndex === -1) { );
return res
.status(404) if (radarrIndex === -1) {
.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 // If we are setting this as the default, clear any previous defaults for the same type first
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
// and are the default // and are the default
if (req.body.isDefault) { if (req.body.isDefault) {
settings.radarr settings.radarr
.filter((radarrInstance) => radarrInstance.is4k === req.body.is4k) .filter((radarrInstance) => radarrInstance.is4k === req.body.is4k)
.forEach((radarrInstance) => { .forEach((radarrInstance) => {
radarrInstance.isDefault = false; radarrInstance.isDefault = false;
}); });
}
settings.radarr[radarrIndex] = {
...req.body,
id: Number(req.params.id),
} as RadarrSettings;
settings.save();
return res.status(200).json(settings.radarr[radarrIndex]);
} }
);
settings.radarr[radarrIndex] = { radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res, next) => {
...req.body,
id: Number(req.params.id),
} as RadarrSettings;
settings.save();
return res.status(200).json(settings.radarr[radarrIndex]);
});
radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => {
const settings = getSettings(); const settings = getSettings();
const radarrSettings = settings.radarr.find( const radarrSettings = settings.radarr.find(
@ -103,14 +110,12 @@ radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => {
); );
if (!radarrSettings) { if (!radarrSettings) {
return res return next({ status: '404', message: 'Settings instance not found' });
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
} }
const radarr = new RadarrAPI({ const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey, apiKey: radarrSettings.apiKey,
url: RadarrAPI.buildRadarrUrl(radarrSettings, '/api/v3'), url: RadarrAPI.buildUrl(radarrSettings, '/api/v3'),
}); });
const profiles = await radarr.getProfiles(); 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 settings = getSettings();
const radarrIndex = settings.radarr.findIndex( const radarrIndex = settings.radarr.findIndex(
@ -131,9 +136,7 @@ radarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
); );
if (radarrIndex === -1) { if (radarrIndex === -1) {
return res return next({ status: '404', message: 'Settings instance not found' });
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
} }
const removed = settings.radarr.splice(radarrIndex, 1); const removed = settings.radarr.splice(radarrIndex, 1);

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

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

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

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

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

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

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

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

Loading…
Cancel
Save