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 | 59 +++++---- 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, 358 insertions(+), 32 deletions(-) create mode 100644 server/migration/1612571545781-AddLanguageProfileId.ts diff --git a/overseerr-api.yml b/overseerr-api.yml index c49da2ca..c28079db 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 681cb1f3..1283c0bf 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 4337d014..d36e2872 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 fb4b2cd5..3bfa289e 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 f5ac5e8e..be09d45d 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 00000000..fa89d81b --- /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 25ba9c0e..7a23ebff 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 94b2bc72..8bf4ffce 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(); + 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, - }, - 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); + 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 409530f7..71627b78 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 c7db928b..39be37bf 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} +
+ )} +
+