From 53f6f59798fa7e3f95959990a3df555db3c1c51e Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Sun, 7 Feb 2021 16:33:18 +0100 Subject: [PATCH] feat(requests): add language profile support (#860) --- overseerr-api.yml | 9 ++ server/api/sonarr.ts | 29 +++++ server/entity/MediaRequest.ts | 22 ++++ server/interfaces/api/serviceInterfaces.ts | 4 + server/lib/settings.ts | 2 + .../1612571545781-AddLanguageProfileId.ts | 31 +++++ server/routes/request.ts | 1 + server/routes/service.ts | 61 +++++---- server/routes/settings/sonarr.ts | 2 + .../RequestModal/AdvancedRequester/index.tsx | 101 +++++++++++++-- .../RequestModal/TvRequestModal.tsx | 3 + src/components/Settings/SonarrModal/index.tsx | 119 +++++++++++++++++- src/i18n/locale/en.json | 8 ++ 13 files changed, 359 insertions(+), 33 deletions(-) create mode 100644 server/migration/1612571545781-AddLanguageProfileId.ts diff --git a/overseerr-api.yml b/overseerr-api.yml index c49da2cad..c28079dbc 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -376,9 +376,16 @@ components: activeDirectory: type: string example: '/tv/' + activeLanguageProfileId: + type: number + example: 1 + nullable: true activeAnimeProfileId: type: number nullable: true + activeAnimeLanguageProfileId: + type: number + nullable: true activeAnimeProfileName: type: string example: 720p/1080p @@ -3062,6 +3069,8 @@ paths: type: number rootFolder: type: string + languageProfileId: + type: number required: - mediaType - mediaId diff --git a/server/api/sonarr.ts b/server/api/sonarr.ts index 681cb1f3a..1283c0bf8 100644 --- a/server/api/sonarr.ts +++ b/server/api/sonarr.ts @@ -112,6 +112,7 @@ interface AddSeriesOptions { tvdbid: number; title: string; profileId: number; + languageProfileId?: number; seasons: number[]; seasonFolder: boolean; rootFolderPath: string; @@ -120,6 +121,11 @@ interface AddSeriesOptions { searchNow?: boolean; } +export interface LanguageProfile { + id: number; + name: string; +} + class SonarrAPI extends ExternalAPI { static buildSonarrUrl(sonarrSettings: SonarrSettings, path?: string): string { return `${sonarrSettings.useSsl ? 'https' : 'http'}://${ @@ -236,6 +242,7 @@ class SonarrAPI extends ExternalAPI { tvdbId: options.tvdbid, title: options.title, profileId: options.profileId, + languageProfileId: options.languageProfileId, seasons: this.buildSeasonList( options.seasons, series.seasons.map((season) => ({ @@ -321,6 +328,28 @@ class SonarrAPI extends ExternalAPI { } } + public async getLanguageProfiles(): Promise { + try { + const data = await this.getRolling( + '/v3/languageprofile', + undefined, + 3600 + ); + + return data; + } catch (e) { + logger.error( + 'Something went wrong while retrieving Sonarr language profiles.', + { + label: 'Sonarr API', + message: e.message, + } + ); + + throw new Error('Failed to get language profiles'); + } + } + private buildSeasonList( seasons: number[], existingSeasons?: SonarrSeason[] diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 4337d0144..d36e28728 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -78,6 +78,9 @@ export class MediaRequest { @Column({ nullable: true }) public rootFolder: string; + @Column({ nullable: true }) + public languageProfileId: number; + constructor(init?: Partial) { Object.assign(this, init); } @@ -559,6 +562,11 @@ export class MediaRequest { ? sonarrSettings.activeAnimeProfileId : sonarrSettings.activeProfileId; + let languageProfile = + seriesType === 'anime' && sonarrSettings.activeAnimeLanguageProfileId + ? sonarrSettings.activeAnimeLanguageProfileId + : sonarrSettings.activeLanguageProfileId; + if ( this.rootFolder && this.rootFolder !== '' && @@ -577,10 +585,24 @@ export class MediaRequest { }); } + if ( + this.languageProfileId && + this.languageProfileId !== languageProfile + ) { + languageProfile = this.languageProfileId; + logger.info( + `Request has an override Language Profile: ${languageProfile}`, + { + label: 'Media Request', + } + ); + } + // Run this asynchronously so we don't wait for it on the UI side sonarr .addSeries({ profileId: qualityProfile, + languageProfileId: languageProfile, rootFolderPath: rootFolder, title: series.name, tvdbid: tvdbId, diff --git a/server/interfaces/api/serviceInterfaces.ts b/server/interfaces/api/serviceInterfaces.ts index fb4b2cd56..3bfa289eb 100644 --- a/server/interfaces/api/serviceInterfaces.ts +++ b/server/interfaces/api/serviceInterfaces.ts @@ -1,4 +1,5 @@ import { RadarrProfile, RadarrRootFolder } from '../../api/radarr'; +import { LanguageProfile } from '../../api/sonarr'; export interface ServiceCommonServer { id: number; @@ -7,12 +8,15 @@ export interface ServiceCommonServer { isDefault: boolean; activeProfileId: number; activeDirectory: string; + activeLanguageProfileId?: number; activeAnimeProfileId?: number; activeAnimeDirectory?: string; + activeAnimeLanguageProfileId?: number; } export interface ServiceCommonServerWithDetails { server: ServiceCommonServer; profiles: RadarrProfile[]; rootFolders: Partial[]; + languageProfiles?: LanguageProfile[]; } diff --git a/server/lib/settings.ts b/server/lib/settings.ts index f5ac5e8e8..be09d45db 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -45,6 +45,8 @@ export interface SonarrSettings extends DVRSettings { activeAnimeProfileId?: number; activeAnimeProfileName?: string; activeAnimeDirectory?: string; + activeAnimeLanguageProfileId?: number; + activeLanguageProfileId?: number; enableSeasonFolders: boolean; } diff --git a/server/migration/1612571545781-AddLanguageProfileId.ts b/server/migration/1612571545781-AddLanguageProfileId.ts new file mode 100644 index 000000000..fa89d81b7 --- /dev/null +++ b/server/migration/1612571545781-AddLanguageProfileId.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddLanguageProfileId1612571545781 implements MigrationInterface { + name = 'AddLanguageProfileId1612571545781'; + + public async up(queryRunner: QueryRunner): Promise { + 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, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder" 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 { + 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, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder" FROM "temporary_media_request"` + ); + await queryRunner.query(`DROP TABLE "temporary_media_request"`); + } +} diff --git a/server/routes/request.ts b/server/routes/request.ts index 25ba9c0ee..7a23ebff9 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -250,6 +250,7 @@ requestRoutes.post( serverId: req.body.serverId, profileId: req.body.profileId, rootFolder: req.body.rootFolder, + languageProfileId: req.body.languageProfileId, seasons: finalSeasons.map( (sn) => new SeasonRequest({ diff --git a/server/routes/service.ts b/server/routes/service.ts index 94b2bc727..8bf4ffce8 100644 --- a/server/routes/service.ts +++ b/server/routes/service.ts @@ -90,6 +90,8 @@ serviceRoutes.get('/sonarr', async (req, res) => { activeProfileId: sonarr.activeProfileId, activeAnimeProfileId: sonarr.activeAnimeProfileId, activeAnimeDirectory: sonarr.activeAnimeDirectory, + activeLanguageProfileId: sonarr.activeLanguageProfileId, + activeAnimeLanguageProfileId: sonarr.activeAnimeLanguageProfileId, }) ); @@ -119,31 +121,40 @@ serviceRoutes.get<{ sonarrId: string }>( }:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`, }); - const profiles = await sonarr.getProfiles(); - const rootFolders = await sonarr.getRootFolders(); - - return res.status(200).json({ - server: { - id: sonarrSettings.id, - name: sonarrSettings.name, - is4k: sonarrSettings.is4k, - isDefault: sonarrSettings.isDefault, - activeDirectory: sonarrSettings.activeDirectory, - activeProfileId: sonarrSettings.activeProfileId, - activeAnimeProfileId: sonarrSettings.activeAnimeProfileId, - activeAnimeDirectory: sonarrSettings.activeAnimeDirectory, - }, - profiles: profiles.map((profile) => ({ - id: profile.id, - name: profile.name, - })), - rootFolders: rootFolders.map((folder) => ({ - id: folder.id, - freeSpace: folder.freeSpace, - path: folder.path, - totalSpace: folder.totalSpace, - })), - } as ServiceCommonServerWithDetails); + try { + const profiles = await sonarr.getProfiles(); + const rootFolders = await sonarr.getRootFolders(); + const languageProfiles = await sonarr.getLanguageProfiles(); + + return res.status(200).json({ + server: { + id: sonarrSettings.id, + name: sonarrSettings.name, + is4k: sonarrSettings.is4k, + isDefault: sonarrSettings.isDefault, + activeDirectory: sonarrSettings.activeDirectory, + activeProfileId: sonarrSettings.activeProfileId, + activeAnimeProfileId: sonarrSettings.activeAnimeProfileId, + activeAnimeDirectory: sonarrSettings.activeAnimeDirectory, + activeLanguageProfileId: sonarrSettings.activeLanguageProfileId, + activeAnimeLanguageProfileId: + sonarrSettings.activeAnimeLanguageProfileId, + }, + profiles: profiles.map((profile) => ({ + id: profile.id, + name: profile.name, + })), + rootFolders: rootFolders.map((folder) => ({ + id: folder.id, + freeSpace: folder.freeSpace, + path: folder.path, + totalSpace: folder.totalSpace, + })), + languageProfiles: languageProfiles, + } as ServiceCommonServerWithDetails); + } catch (e) { + next({ status: 500, message: e.message }); + } } ); diff --git a/server/routes/settings/sonarr.ts b/server/routes/settings/sonarr.ts index 409530f7d..71627b78c 100644 --- a/server/routes/settings/sonarr.ts +++ b/server/routes/settings/sonarr.ts @@ -46,6 +46,7 @@ sonarrRoutes.post('/test', async (req, res, next) => { const profiles = await sonarr.getProfiles(); const folders = await sonarr.getRootFolders(); + const languageProfiles = await sonarr.getLanguageProfiles(); return res.status(200).json({ profiles, @@ -53,6 +54,7 @@ sonarrRoutes.post('/test', async (req, res, next) => { id: folder.id, path: folder.path, })), + languageProfiles, }); } catch (e) { logger.error('Failed to test Sonarr', { diff --git a/src/components/RequestModal/AdvancedRequester/index.tsx b/src/components/RequestModal/AdvancedRequester/index.tsx index c7db928b0..39be37bf0 100644 --- a/src/components/RequestModal/AdvancedRequester/index.tsx +++ b/src/components/RequestModal/AdvancedRequester/index.tsx @@ -21,12 +21,15 @@ const messages = defineMessages({ loadingprofiles: 'Loading profiles…', loadingfolders: 'Loading folders…', requestas: 'Request As', + languageprofile: 'Language Profile', + loadinglanguages: 'Loading languages…', }); export type RequestOverrides = { server?: number; profile?: number; folder?: string; + language?: number; user?: User; }; @@ -69,6 +72,11 @@ const AdvancedRequester: React.FC = ({ const [selectedFolder, setSelectedFolder] = useState( defaultOverrides?.folder ?? '' ); + + const [selectedLanguage, setSelectedLanguage] = useState( + defaultOverrides?.language ?? -1 + ); + const { data: serverData, isValidating, @@ -135,6 +143,13 @@ const AdvancedRequester: React.FC = ({ ? serverData.server.activeAnimeDirectory : serverData.server.activeDirectory) ); + const defaultLanguage = serverData.languageProfiles?.find( + (language) => + language.id === + (isAnime + ? serverData.server.activeAnimeLanguageProfileId + : serverData.server.activeLanguageProfileId) + ); if ( defaultProfile && @@ -149,7 +164,15 @@ const AdvancedRequester: React.FC = ({ defaultFolder.path !== selectedFolder && (!defaultOverrides || defaultOverrides.folder === null) ) { - setSelectedFolder(defaultFolder?.path ?? ''); + setSelectedFolder(defaultFolder.path ?? ''); + } + + if ( + defaultLanguage && + defaultLanguage.id !== selectedLanguage && + (!defaultOverrides || defaultOverrides.language === null) + ) { + setSelectedLanguage(defaultLanguage.id); } } }, [serverData]); @@ -178,10 +201,19 @@ const AdvancedRequester: React.FC = ({ ) { setSelectedFolder(defaultOverrides.folder); } + + if ( + defaultOverrides && + defaultOverrides.language !== null && + defaultOverrides.language !== undefined + ) { + setSelectedLanguage(defaultOverrides.language); + } }, [ defaultOverrides?.server, defaultOverrides?.folder, defaultOverrides?.profile, + defaultOverrides?.language, ]); useEffect(() => { @@ -191,9 +223,16 @@ const AdvancedRequester: React.FC = ({ profile: selectedProfile !== -1 ? selectedProfile : undefined, server: selectedServer ?? undefined, user: selectedUser ?? undefined, + language: selectedLanguage ?? undefined, }); } - }, [selectedFolder, selectedServer, selectedProfile, selectedUser]); + }, [ + selectedFolder, + selectedServer, + selectedProfile, + selectedUser, + selectedLanguage, + ]); if (!data && !error) { return ( @@ -225,7 +264,7 @@ const AdvancedRequester: React.FC = ({ {!!data && selectedServer !== null && ( <>
-
+
@@ -247,8 +286,8 @@ const AdvancedRequester: React.FC = ({ ))}
-
-
+
+ +
+
+ + + {testResponse.languageProfiles.length > 0 && + testResponse.languageProfiles.map((language) => ( + + ))} + +
+ {errors.activeLanguageProfileId && + touched.activeLanguageProfileId && ( +
+ {errors.activeLanguageProfileId} +
+ )} +
+
+
+ +
+
+ + + {testResponse.languageProfiles.length > 0 && + testResponse.languageProfiles.map((language) => ( + + ))} + +
+ {errors.activeAnimeLanguageProfileId && + touched.activeAnimeLanguageProfileId && ( +
+ {errors.activeAnimeLanguageProfileId} +
+ )} +
+