diff --git a/seerr-api.yml b/seerr-api.yml index 18f3361d6..83e0202ea 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -15,7 +15,8 @@ tags: - name: settings description: Endpoints related to Seerr's settings and configuration. - name: auth - description: Endpoints related to logging in or out, and the currently authenticated user. + description: Endpoints related to logging in or out, and the currently + authenticated user. - name: users description: Endpoints related to user management. - name: search @@ -152,6 +153,10 @@ components: type: string nullable: true example: 'en' + mediaLocale: + type: string + nullable: true + example: 'en' discoverRegion: type: string nullable: true @@ -1239,7 +1244,9 @@ components: status: type: number example: 0 - description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED + description: + Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = + DECLINED readOnly: true media: $ref: '#/components/schemas/MediaInfo' @@ -1286,7 +1293,10 @@ components: status: type: number example: 0 - description: Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`, 6 = `DELETED` + description: + Availability of the media. 1 = `UNKNOWN`, 2 = `PENDING`, 3 = + `PROCESSING`, 4 = `PARTIALLY_AVAILABLE`, 5 = `AVAILABLE`, 6 = + `DELETED` requests: type: array readOnly: true @@ -2173,7 +2183,9 @@ paths: /status/appdata: get: summary: Get application data volume status - description: For Docker installs, returns whether or not the volume mount was configured properly. Always returns true for non-Docker installs. + description: + For Docker installs, returns whether or not the volume mount was + configured properly. Always returns true for non-Docker installs. security: [] tags: - public @@ -2317,7 +2329,9 @@ paths: name: enable explode: false allowReserved: true - description: Comma separated list of libraries to enable. Any libraries not passed will be disabled! + description: + Comma separated list of libraries to enable. Any libraries not + passed will be disabled! schema: type: string nullable: true @@ -2386,7 +2400,9 @@ paths: $ref: '#/components/schemas/JellyfinLibrary' post: summary: Start full Jellyfin library sync - description: Runs a full Jellyfin library sync and returns the progress in a JSON array. + description: + Runs a full Jellyfin library sync and returns the progress in a + JSON array. tags: - settings requestBody: @@ -2472,7 +2488,9 @@ paths: name: enable explode: false allowReserved: true - description: Comma separated list of libraries to enable. Any libraries not passed will be disabled! + description: + Comma separated list of libraries to enable. Any libraries not + passed will be disabled! schema: type: string nullable: true @@ -2633,7 +2651,8 @@ paths: /settings/metadatas/test: post: summary: Test Provider configuration - description: Tests if the TVDB configuration is valid. Returns a list of available languages on success. + description: Tests if the TVDB configuration is valid. Returns a list of + available languages on success. tags: - settings requestBody: @@ -2727,7 +2746,9 @@ paths: /settings/radarr/test: post: summary: Test Radarr configuration - description: Tests if the Radarr configuration is valid. Returns profiles and root folders on success. + description: + Tests if the Radarr configuration is valid. Returns profiles and + root folders on success. tags: - settings requestBody: @@ -2816,7 +2837,9 @@ paths: /settings/radarr/{radarrId}/profiles: get: summary: Get available Radarr profiles - description: Returns a list of profiles available on the Radarr server instance in a JSON array. + description: + Returns a list of profiles available on the Radarr server instance + in a JSON array. tags: - settings parameters: @@ -2871,7 +2894,9 @@ paths: /settings/sonarr/test: post: summary: Test Sonarr configuration - description: Tests if the Sonarr configuration is valid. Returns profiles and root folders on success. + description: + Tests if the Sonarr configuration is valid. Returns profiles and + root folders on success. tags: - settings requestBody: @@ -2961,7 +2986,9 @@ paths: get: summary: Get public settings security: [] - description: Returns settings that are not protected or sensitive. Mainly used to determine if the application has been configured for the first time. + description: + Returns settings that are not protected or sensitive. Mainly used + to determine if the application has been configured for the first time. tags: - settings responses: @@ -2974,7 +3001,9 @@ paths: /settings/initialize: post: summary: Initialize application - description: Sets the app as initialized, allowing the user to navigate to pages other than the setup page. + description: + Sets the app as initialized, allowing the user to navigate to pages + other than the setup page. tags: - settings responses: @@ -2987,7 +3016,9 @@ paths: /settings/jobs: get: summary: Get scheduled jobs - description: Returns list of all scheduled jobs and details about their next execution time in a JSON array. + description: + Returns list of all scheduled jobs and details about their next + execution time in a JSON array. tags: - settings responses: @@ -3002,7 +3033,9 @@ paths: /settings/jobs/{jobId}/run: post: summary: Invoke a specific job - description: Invokes a specific job to run. Will return the new job status in JSON format. + description: + Invokes a specific job to run. Will return the new job status in + JSON format. tags: - settings parameters: @@ -3040,7 +3073,9 @@ paths: /settings/jobs/{jobId}/schedule: post: summary: Modify job schedule - description: Re-registers the job with the schedule specified. Will return the job in JSON format. + description: + Re-registers the job with the schedule specified. Will return the + job in JSON format. tags: - settings parameters: @@ -3836,7 +3871,9 @@ paths: $ref: '#/components/schemas/DiscoverSlider' delete: summary: Delete slider by ID - description: Deletes the slider with the provided sliderId. Requires the `ADMIN` permission. + description: + Deletes the slider with the provided sliderId. Requires the `ADMIN` + permission. tags: - settings parameters: @@ -3885,7 +3922,9 @@ paths: /settings/discover/reset: get: summary: Reset all discover sliders - description: Resets all discovery sliders to the default values. Requires the `ADMIN` permission. + description: + Resets all discovery sliders to the default values. Requires the + `ADMIN` permission. tags: - settings responses: @@ -3938,7 +3977,12 @@ paths: /auth/plex: post: summary: Sign in using a Plex token - description: Takes an `authToken` (Plex token) to log the user in. Generates a session cookie for use in further requests. If the user does not exist, and there are no other users, then a user will be created with full admin privileges. If a user logs in with access to the main Plex server, they will also have an account created, but without any permissions. + description: + Takes an `authToken` (Plex token) to log the user in. Generates a + session cookie for use in further requests. If the user does not exist, + and there are no other users, then a user will be created with full + admin privileges. If a user logs in with access to the main Plex server, + they will also have an account created, but without any permissions. security: [] tags: - auth @@ -3963,7 +4007,12 @@ paths: /auth/jellyfin: post: summary: Sign in using a Jellyfin username and password - description: Takes the user's username and password to log the user in. Generates a session cookie for use in further requests. If the user does not exist, and there are no other users, then a user will be created with full admin privileges. If a user logs in with access to the Jellyfin server, they will also have an account created, but without any permissions. + description: Takes the user's username and password to log the user in. + Generates a session cookie for use in further requests. If the user does + not exist, and there are no other users, then a user will be created + with full admin privileges. If a user logs in with access to the + Jellyfin server, they will also have an account created, but without any + permissions. security: [] tags: - auth @@ -3997,7 +4046,9 @@ paths: /auth/local: post: summary: Sign in using a local account - description: Takes an `email` and a `password` to log the user in. Generates a session cookie for use in further requests. + description: + Takes an `email` and a `password` to log the user in. Generates a + session cookie for use in further requests. security: [] tags: - auth @@ -4025,7 +4076,8 @@ paths: /auth/logout: post: summary: Sign out and clear session cookie - description: Completely clear the session cookie and associated values, effectively signing the user out. + description: Completely clear the session cookie and associated values, + effectively signing the user out. tags: - auth responses: @@ -4435,7 +4487,8 @@ paths: $ref: '#/components/schemas/User' delete: summary: Delete user by ID - description: Deletes the user with the provided userId. Requires the `MANAGE_USERS` permission. + description: Deletes the user with the provided userId. Requires the + `MANAGE_USERS` permission. tags: - users parameters: @@ -4968,7 +5021,8 @@ paths: /user/{userId}/settings/main: get: summary: Get general settings for a user - description: Returns general settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. + description: Returns general settings for a specific user. Requires + `MANAGE_USERS` permission if viewing other users. tags: - users parameters: @@ -4986,7 +5040,9 @@ paths: $ref: '#/components/schemas/UserSettings' post: summary: Update general settings for a user - description: Updates and returns general settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. + description: + Updates and returns general settings for a specific user. Requires + `MANAGE_USERS` permission if editing other users. tags: - users parameters: @@ -5011,7 +5067,9 @@ paths: /user/{userId}/settings/password: get: summary: Get password page informatiom - description: Returns important data for the password page to function correctly. Requires `MANAGE_USERS` permission if viewing other users. + description: + Returns important data for the password page to function correctly. + Requires `MANAGE_USERS` permission if viewing other users. tags: - users parameters: @@ -5033,7 +5091,9 @@ paths: example: true post: summary: Update password for a user - description: Updates a user's password. Requires `MANAGE_USERS` permission if editing other users. + description: + Updates a user's password. Requires `MANAGE_USERS` permission if + editing other users. tags: - users parameters: @@ -5062,7 +5122,9 @@ paths: /user/{userId}/settings/linked-accounts/plex: post: summary: Link the provided Plex account to the current user - description: Logs in to Plex with the provided auth token, then links the associated Plex account with the user's account. Users can only link external accounts to their own account. + description: Logs in to Plex with the provided auth token, then links the + associated Plex account with the user's account. Users can only link + external accounts to their own account. tags: - users parameters: @@ -5091,7 +5153,8 @@ paths: description: Account already linked to a user delete: summary: Remove the linked Plex account for a user - description: Removes the linked Plex account for a specific user. Requires `MANAGE_USERS` permission if editing other users. + description: Removes the linked Plex account for a specific user. Requires + `MANAGE_USERS` permission if editing other users. tags: - users parameters: @@ -5110,7 +5173,10 @@ paths: /user/{userId}/settings/linked-accounts/jellyfin: post: summary: Link the provided Jellyfin account to the current user - description: Logs in to Jellyfin with the provided credentials, then links the associated Jellyfin account with the user's account. Users can only link external accounts to their own account. + description: + Logs in to Jellyfin with the provided credentials, then links the + associated Jellyfin account with the user's account. Users can only link + external accounts to their own account. tags: - users parameters: @@ -5141,7 +5207,9 @@ paths: description: Account already linked to a user delete: summary: Remove the linked Jellyfin account for a user - description: Removes the linked Jellyfin account for a specific user. Requires `MANAGE_USERS` permission if editing other users. + description: + Removes the linked Jellyfin account for a specific user. Requires + `MANAGE_USERS` permission if editing other users. tags: - users parameters: @@ -5160,7 +5228,8 @@ paths: /user/{userId}/settings/notifications: get: summary: Get notification settings for a user - description: Returns notification settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. + description: Returns notification settings for a specific user. Requires + `MANAGE_USERS` permission if viewing other users. tags: - users parameters: @@ -5178,7 +5247,9 @@ paths: $ref: '#/components/schemas/UserSettingsNotifications' post: summary: Update notification settings for a user - description: Updates and returns notification settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. + description: + Updates and returns notification settings for a specific user. + Requires `MANAGE_USERS` permission if editing other users. tags: - users parameters: @@ -5203,7 +5274,8 @@ paths: /user/{userId}/settings/permissions: get: summary: Get permission settings for a user - description: Returns permission settings for a specific user. Requires `MANAGE_USERS` permission if viewing other users. + description: Returns permission settings for a specific user. Requires + `MANAGE_USERS` permission if viewing other users. tags: - users parameters: @@ -5225,7 +5297,8 @@ paths: example: 2 post: summary: Update permission settings for a user - description: Updates and returns permission settings for a specific user. Requires `MANAGE_USERS` permission if editing other users. + description: Updates and returns permission settings for a specific user. + Requires `MANAGE_USERS` permission if editing other users. tags: - users parameters: @@ -5376,7 +5449,9 @@ paths: /search/company: get: summary: Search for companies - description: Returns a list of TMDB companies matching the search query. (Will not return origin country) + description: + Returns a list of TMDB companies matching the search query. (Will + not return origin country) tags: - search parameters: @@ -5512,19 +5587,25 @@ paths: schema: type: string example: PG-13 - description: Exact certification to filter by (used when certificationMode is 'exact') + description: + Exact certification to filter by (used when certificationMode is + 'exact') - in: query name: certificationGte schema: type: string example: G - description: Minimum certification to filter by (used when certificationMode is 'range') + description: + Minimum certification to filter by (used when certificationMode is + 'range') - in: query name: certificationLte schema: type: string example: PG-13 - description: Maximum certification to filter by (used when certificationMode is 'range') + description: + Maximum certification to filter by (used when certificationMode is + 'range') - in: query name: certificationCountry schema: @@ -5537,7 +5618,9 @@ paths: type: string enum: [exact, range] example: exact - description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API) + description: + Determines whether to use exact certification matching or a + certification range (internal use only, not sent to TMDB API) responses: '200': description: Results @@ -5609,7 +5692,9 @@ paths: /discover/movies/language/{language}: get: summary: Discover movies by original language - description: Returns a list of movies based on the provided ISO 639-1 language code in a JSON object. + description: + Returns a list of movies based on the provided ISO 639-1 language + code in a JSON object. tags: - search parameters: @@ -5656,7 +5741,9 @@ paths: /discover/movies/studio/{studioId}: get: summary: Discover movies by studio - description: Returns a list of movies based on the provided studio ID in a JSON object. + description: + Returns a list of movies based on the provided studio ID in a JSON + object. tags: - search parameters: @@ -5843,19 +5930,25 @@ paths: schema: type: string example: TV-14 - description: Exact certification to filter by (used when certificationMode is 'exact') + description: + Exact certification to filter by (used when certificationMode is + 'exact') - in: query name: certificationGte schema: type: string example: TV-PG - description: Minimum certification to filter by (used when certificationMode is 'range') + description: + Minimum certification to filter by (used when certificationMode is + 'range') - in: query name: certificationLte schema: type: string example: TV-MA - description: Maximum certification to filter by (used when certificationMode is 'range') + description: + Maximum certification to filter by (used when certificationMode is + 'range') - in: query name: certificationCountry schema: @@ -5868,7 +5961,9 @@ paths: type: string enum: [exact, range] example: exact - description: Determines whether to use exact certification matching or a certification range (internal use only, not sent to TMDB API) + description: + Determines whether to use exact certification matching or a + certification range (internal use only, not sent to TMDB API) responses: '200': description: Results @@ -5893,7 +5988,9 @@ paths: /discover/tv/language/{language}: get: summary: Discover TV shows by original language - description: Returns a list of TV shows based on the provided ISO 639-1 language code in a JSON object. + description: + Returns a list of TV shows based on the provided ISO 639-1 language + code in a JSON object. tags: - search parameters: @@ -5940,7 +6037,9 @@ paths: /discover/tv/genre/{genreId}: get: summary: Discover TV shows by genre - description: Returns a list of TV shows based on the provided genre ID in a JSON object. + description: + Returns a list of TV shows based on the provided genre ID in a JSON + object. tags: - search parameters: @@ -5987,7 +6086,9 @@ paths: /discover/tv/network/{networkId}: get: summary: Discover TV shows by network - description: Returns a list of TV shows based on the provided network ID in a JSON object. + description: + Returns a list of TV shows based on the provided network ID in a + JSON object. tags: - search parameters: @@ -6470,7 +6571,9 @@ paths: $ref: '#/components/schemas/MediaRequest' put: summary: Update MediaRequest - description: Updates a specific media request and returns the request in a JSON object. Requires the `MANAGE_REQUESTS` permission. + description: + Updates a specific media request and returns the request in a JSON + object. Requires the `MANAGE_REQUESTS` permission. tags: - request parameters: @@ -6521,7 +6624,9 @@ paths: $ref: '#/components/schemas/MediaRequest' delete: summary: Delete request - description: Removes a request. If the user has the `MANAGE_REQUESTS` permission, any request can be removed. Otherwise, only pending requests can be removed. + description: Removes a request. If the user has the `MANAGE_REQUESTS` + permission, any request can be removed. Otherwise, only pending requests + can be removed. tags: - request parameters: @@ -6618,7 +6723,9 @@ paths: /movie/{movieId}/recommendations: get: summary: Get recommended movies - description: Returns list of recommended movies based on provided movie ID in a JSON object. + description: + Returns list of recommended movies based on provided movie ID in a + JSON object. tags: - movies parameters: @@ -6663,7 +6770,9 @@ paths: /movie/{movieId}/similar: get: summary: Get similar movies - description: Returns list of similar movies based on the provided movieId in a JSON object. + description: + Returns list of similar movies based on the provided movieId in a + JSON object. tags: - movies parameters: @@ -6750,7 +6859,9 @@ paths: /movie/{movieId}/ratingscombined: get: summary: Get RT and IMDB movie ratings combined - description: Returns ratings from RottenTomatoes and IMDB based on the provided movieId in a JSON object. + description: + Returns ratings from RottenTomatoes and IMDB based on the provided + movieId in a JSON object. tags: - movies parameters: @@ -6863,7 +6974,9 @@ paths: /tv/{tvId}/recommendations: get: summary: Get recommended TV series - description: Returns list of recommended TV series based on the provided tvId in a JSON object. + description: + Returns list of recommended TV series based on the provided tvId in + a JSON object. tags: - tv parameters: @@ -6908,7 +7021,9 @@ paths: /tv/{tvId}/similar: get: summary: Get similar TV series - description: Returns list of similar TV series based on the provided tvId in a JSON object. + description: + Returns list of similar TV series based on the provided tvId in a + JSON object. tags: - tv parameters: @@ -7014,7 +7129,8 @@ paths: /person/{personId}/combined_credits: get: summary: Get combined credits - description: Returns the person's combined credits based on the provided personId in a JSON object. + description: Returns the person's combined credits based on the provided + personId in a JSON object. tags: - person parameters: @@ -7104,7 +7220,9 @@ paths: /media/{mediaId}: delete: summary: Delete media item - description: Removes a media item. The `MANAGE_REQUESTS` permission is required to perform this action. + description: + Removes a media item. The `MANAGE_REQUESTS` permission is required + to perform this action. tags: - media parameters: @@ -7121,7 +7239,9 @@ paths: /media/{mediaId}/file: delete: summary: Delete media file - description: Removes a media file from radarr/sonarr. The `ADMIN` permission is required to perform this action. + description: + Removes a media file from radarr/sonarr. The `ADMIN` permission is + required to perform this action. tags: - media parameters: @@ -7134,7 +7254,9 @@ paths: type: string - in: query name: is4k - description: Whether to remove from 4K service instance (true) or regular service instance (false) + description: + Whether to remove from 4K service instance (true) or regular + service instance (false) required: false example: false schema: @@ -7278,7 +7400,9 @@ paths: /service/radarr/{radarrId}: get: summary: Get Radarr server quality profiles and root folders - description: Returns a Radarr server's quality profile and root folder details in a JSON object. + description: + Returns a Radarr server's quality profile and root folder details + in a JSON object. tags: - service parameters: @@ -7318,7 +7442,9 @@ paths: /service/sonarr/{sonarrId}: get: summary: Get Sonarr server quality profiles and root folders - description: Returns a Sonarr server's quality profile and root folder details in a JSON object. + description: + Returns a Sonarr server's quality profile and root folder details + in a JSON object. tags: - service parameters: @@ -7655,7 +7781,10 @@ paths: $ref: '#/components/schemas/Issue' delete: summary: Delete issue - description: Removes an issue. If the user has the `MANAGE_ISSUES` permission, any issue can be removed. Otherwise, only a users own issues can be removed. + description: + Removes an issue. If the user has the `MANAGE_ISSUES` permission, + any issue can be removed. Otherwise, only a users own issues can be + removed. tags: - issue parameters: diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index d0f2ef1f7..261512140 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -43,6 +43,9 @@ export class UserSettings { @Column({ default: '' }) public locale?: string; + @Column({ nullable: true }) + public mediaLocale?: string; + @Column({ nullable: true }) public discoverRegion?: string; diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 57e7b3f61..5fb8ac5ab 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -4,6 +4,7 @@ export interface UserSettingsGeneralResponse { username?: string; email?: string; locale?: string; + mediaLocale?: string; discoverRegion?: string; streamingRegion?: string; originalLanguage?: string; diff --git a/server/migration/postgres/1777644799789-AddMediaLanguageSetting.ts b/server/migration/postgres/1777644799789-AddMediaLanguageSetting.ts new file mode 100644 index 000000000..d117db3ef --- /dev/null +++ b/server/migration/postgres/1777644799789-AddMediaLanguageSetting.ts @@ -0,0 +1,17 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMediaLanguageSetting1777644799789 implements MigrationInterface { + name = 'AddMediaLanguageSetting1777644799789'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" ADD "mediaLocale" character varying` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" DROP COLUMN "mediaLocale"` + ); + } +} diff --git a/server/migration/sqlite/1777644713621-AddMediaLanguageSetting.ts b/server/migration/sqlite/1777644713621-AddMediaLanguageSetting.ts new file mode 100644 index 000000000..ae9ace767 --- /dev/null +++ b/server/migration/sqlite/1777644713621-AddMediaLanguageSetting.ts @@ -0,0 +1,87 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMediaLanguageSetting1777644713621 implements MigrationInterface { + name = 'AddMediaLanguageSetting1777644713621'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordId" varchar, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, "telegramMessageThreadId" varchar, "mediaLocale" varchar, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordId" varchar, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, "telegramMessageThreadId" varchar, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + } +} diff --git a/server/routes/index.ts b/server/routes/index.ts index f701acf96..c939c8e84 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -258,10 +258,11 @@ router.get<{ id: string }>('/network/:id', async (req, res, next) => { router.get('/genres/movie', isAuthenticated(), async (req, res, next) => { const tmdb = new TheMovieDb(); + const mediaLocale = req.user?.settings?.mediaLocale ?? req.locale; try { const genres = await tmdb.getMovieGenres({ - language: (req.query.language as string) ?? req.locale, + language: (req.query.language as string) ?? mediaLocale, }); return res.status(200).json(genres); @@ -279,10 +280,11 @@ router.get('/genres/movie', isAuthenticated(), async (req, res, next) => { router.get('/genres/tv', isAuthenticated(), async (req, res, next) => { const tmdb = new TheMovieDb(); + const mediaLocale = req.user?.settings?.mediaLocale ?? req.locale; try { const genres = await tmdb.getTvGenres({ - language: (req.query.language as string) ?? req.locale, + language: (req.query.language as string) ?? mediaLocale, }); return res.status(200).json(genres); diff --git a/server/routes/movie.ts b/server/routes/movie.ts index bd86e447a..ed64f43cf 100644 --- a/server/routes/movie.ts +++ b/server/routes/movie.ts @@ -15,11 +15,12 @@ const movieRoutes = Router(); movieRoutes.get('/:id', async (req, res, next) => { const tmdb = new TheMovieDb(); + const mediaLocale = req.user?.settings?.mediaLocale ?? req.locale; try { const tmdbMovie = await tmdb.getMovie({ movieId: Number(req.params.id), - language: (req.query.language as string) ?? req.locale, + language: (req.query.language as string) ?? mediaLocale, }); const media = await Media.getMedia(tmdbMovie.id, MediaType.MOVIE); @@ -58,12 +59,13 @@ movieRoutes.get('/:id', async (req, res, next) => { movieRoutes.get('/:id/recommendations', async (req, res, next) => { const tmdb = new TheMovieDb(); + const mediaLocale = req.user?.settings?.mediaLocale ?? req.locale; try { const results = await tmdb.getMovieRecommendations({ movieId: Number(req.params.id), page: Number(req.query.page), - language: (req.query.language as string) ?? req.locale, + language: (req.query.language as string) ?? mediaLocale, }); const media = await Media.getRelatedMedia( @@ -103,12 +105,13 @@ movieRoutes.get('/:id/recommendations', async (req, res, next) => { movieRoutes.get('/:id/similar', async (req, res, next) => { const tmdb = new TheMovieDb(); + const mediaLocale = req.user?.settings?.mediaLocale ?? req.locale; try { const results = await tmdb.getMovieSimilar({ movieId: Number(req.params.id), page: Number(req.query.page), - language: (req.query.language as string) ?? req.locale, + language: (req.query.language as string) ?? mediaLocale, }); const media = await Media.getRelatedMedia( diff --git a/server/routes/person.ts b/server/routes/person.ts index 03566a450..279b6005d 100644 --- a/server/routes/person.ts +++ b/server/routes/person.ts @@ -12,11 +12,12 @@ const personRoutes = Router(); personRoutes.get('/:id', async (req, res, next) => { const tmdb = new TheMovieDb(); + const mediaLocale = req.user?.settings?.mediaLocale ?? req.locale; try { const person = await tmdb.getPerson({ personId: Number(req.params.id), - language: (req.query.language as string) ?? req.locale, + language: (req.query.language as string) ?? mediaLocale, }); return res.status(200).json(mapPersonDetails(person)); } catch (e) { @@ -34,11 +35,12 @@ personRoutes.get('/:id', async (req, res, next) => { personRoutes.get('/:id/combined_credits', async (req, res, next) => { const tmdb = new TheMovieDb(); + const mediaLocale = req.user?.settings?.mediaLocale ?? req.locale; try { const combinedCredits = await tmdb.getPersonCombinedCredits({ personId: Number(req.params.id), - language: (req.query.language as string) ?? req.locale, + language: (req.query.language as string) ?? mediaLocale, }); const castMedia = await Media.getRelatedMedia( diff --git a/server/routes/search.ts b/server/routes/search.ts index cccb38f28..a7204efc6 100644 --- a/server/routes/search.ts +++ b/server/routes/search.ts @@ -10,6 +10,7 @@ const searchRoutes = Router(); searchRoutes.get('/', async (req, res, next) => { const queryString = req.query.query as string; + const mediaLocale = req.user?.settings?.mediaLocale ?? req.locale; const searchProvider = findSearchProvider(queryString.toLowerCase()); let results: TmdbSearchMultiResponse; @@ -20,7 +21,7 @@ searchRoutes.get('/', async (req, res, next) => { .match(searchProvider.pattern) as RegExpMatchArray; results = await searchProvider.search({ id, - language: (req.query.language as string) ?? req.locale, + language: (req.query.language as string) ?? mediaLocale, query: queryString, }); } else { @@ -29,7 +30,7 @@ searchRoutes.get('/', async (req, res, next) => { results = await tmdb.searchMulti({ query: queryString, page: Number(req.query.page), - language: (req.query.language as string) ?? req.locale, + language: (req.query.language as string) ?? mediaLocale, }); } diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 743f407e4..9048f21e2 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -16,6 +16,7 @@ const tvRoutes = Router(); tvRoutes.get('/:id', async (req, res, next) => { const tmdb = new TheMovieDb(); + const mediaLocale = req.user?.settings?.mediaLocale ?? req.locale; try { const tmdbTv = await tmdb.getTvShow({ @@ -28,7 +29,7 @@ tvRoutes.get('/:id', async (req, res, next) => { : await getMetadataProvider('tv'); const tv = await metadataProvider.getTvShow({ tvId: Number(req.params.id), - language: (req.query.language as string) ?? req.locale, + language: (req.query.language as string) ?? mediaLocale, }); const media = await Media.getMedia(tv.id, MediaType.TV); @@ -67,6 +68,8 @@ tvRoutes.get('/:id', async (req, res, next) => { }); tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => { + const mediaLocale = req.user?.settings?.mediaLocale ?? req.locale; + try { const tmdb = new TheMovieDb(); const tmdbTv = await tmdb.getTvShow({ @@ -81,7 +84,7 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => { const season = await metadataProvider.getTvSeason({ tvId: Number(req.params.id), seasonNumber: Number(req.params.seasonNumber), - language: (req.query.language as string) ?? req.locale, + language: (req.query.language as string) ?? mediaLocale, }); return res.status(200).json(mapSeasonWithEpisodes(season)); @@ -101,12 +104,13 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => { tvRoutes.get('/:id/recommendations', async (req, res, next) => { const tmdb = new TheMovieDb(); + const mediaLocale = req.user?.settings?.mediaLocale ?? req.locale; try { const results = await tmdb.getTvRecommendations({ tvId: Number(req.params.id), page: Number(req.query.page), - language: (req.query.language as string) ?? req.locale, + language: (req.query.language as string) ?? mediaLocale, }); const media = await Media.getRelatedMedia( @@ -145,12 +149,13 @@ tvRoutes.get('/:id/recommendations', async (req, res, next) => { tvRoutes.get('/:id/similar', async (req, res, next) => { const tmdb = new TheMovieDb(); + const mediaLocale = req.user?.settings?.mediaLocale ?? req.locale; try { const results = await tmdb.getTvSimilar({ tvId: Number(req.params.id), page: Number(req.query.page), - language: (req.query.language as string) ?? req.locale, + language: (req.query.language as string) ?? mediaLocale, }); const media = await Media.getRelatedMedia( diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index b54254a4f..634d834c0 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -49,6 +49,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>( username: user.username, email: user.email, locale: user.settings?.locale, + mediaLocale: user.settings?.mediaLocale, discoverRegion: user.settings?.discoverRegion, streamingRegion: user.settings?.streamingRegion, originalLanguage: user.settings?.originalLanguage, @@ -122,6 +123,7 @@ userSettingsRoutes.post< user.settings = new UserSettings({ user: req.user, locale: req.body.locale, + mediaLocale: req.body.mediaLocale, discoverRegion: req.body.discoverRegion, streamingRegion: req.body.streamingRegion, originalLanguage: req.body.originalLanguage, @@ -130,6 +132,7 @@ userSettingsRoutes.post< }); } else { user.settings.locale = req.body.locale; + user.settings.mediaLocale = req.body.mediaLocale; user.settings.discoverRegion = req.body.discoverRegion; user.settings.streamingRegion = req.body.streamingRegion; user.settings.originalLanguage = req.body.originalLanguage; @@ -142,6 +145,7 @@ userSettingsRoutes.post< return res.status(200).json({ username: savedUser.username, locale: savedUser.settings?.locale, + mediaLocale: savedUser.settings?.mediaLocale, discoverRegion: savedUser.settings?.discoverRegion, streamingRegion: savedUser.settings?.streamingRegion, originalLanguage: savedUser.settings?.originalLanguage, diff --git a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx index c0e910086..6937a2ccd 100644 --- a/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserGeneralSettings/index.tsx @@ -60,6 +60,7 @@ const messages = defineMessages( seriesrequestlimit: 'Series Request Limit', enableOverride: 'Override Global Limit', applanguage: 'Display Language', + mediaLanguage: 'Media Language', languageDefault: 'Default ({language})', validationemailrequired: 'Email required', validationemailformat: 'Valid email required', @@ -152,6 +153,7 @@ const UserGeneralSettings = () => { displayName: data?.username !== user?.email ? data?.username : '', email: data?.email?.includes('@') ? data.email : '', locale: data?.locale, + mediaLocale: data?.mediaLocale, discoverRegion: data?.discoverRegion, streamingRegion: data?.streamingRegion, originalLanguage: data?.originalLanguage, @@ -171,6 +173,7 @@ const UserGeneralSettings = () => { email: values.email || user?.jellyfinUsername || user?.plexUsername, locale: values.locale, + mediaLocale: values.mediaLocale, discoverRegion: values.discoverRegion, streamingRegion: values.streamingRegion, originalLanguage: values.originalLanguage, @@ -362,6 +365,36 @@ const UserGeneralSettings = () => { +
+ +
+
+ + + {( + Object.keys( + availableLanguages + ) as (keyof typeof availableLanguages)[] + ).map((key) => ( + + ))} + +
+
+