feat(user-settings): add a per-user media language setting

This commit is contained in:
0xsysr3ll
2026-05-01 16:17:27 +02:00
parent 784faa9f84
commit a0aaebb3d8
14 changed files with 365 additions and 76 deletions

View File

@@ -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:

View File

@@ -43,6 +43,9 @@ export class UserSettings {
@Column({ default: '' })
public locale?: string;
@Column({ nullable: true })
public mediaLocale?: string;
@Column({ nullable: true })
public discoverRegion?: string;

View File

@@ -4,6 +4,7 @@ export interface UserSettingsGeneralResponse {
username?: string;
email?: string;
locale?: string;
mediaLocale?: string;
discoverRegion?: string;
streamingRegion?: string;
originalLanguage?: string;

View File

@@ -0,0 +1,17 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMediaLanguageSetting1777644799789 implements MigrationInterface {
name = 'AddMediaLanguageSetting1777644799789';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" ADD "mediaLocale" character varying`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" DROP COLUMN "mediaLocale"`
);
}
}

View File

@@ -0,0 +1,87 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMediaLanguageSetting1777644713621 implements MigrationInterface {
name = 'AddMediaLanguageSetting1777644713621';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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") `
);
}
}

View File

@@ -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);

View File

@@ -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(

View File

@@ -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(

View File

@@ -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,
});
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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 = () => {
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="mediaLocale" className="text-label">
{intl.formatMessage(messages.mediaLanguage)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field as="select" id="mediaLocale" name="mediaLocale">
<option value="" lang={locale}>
{intl.formatMessage(messages.languageDefault, {
language:
availableLanguages[currentSettings.locale].display,
})}
</option>
{(
Object.keys(
availableLanguages
) as (keyof typeof availableLanguages)[]
).map((key) => (
<option
key={key}
value={availableLanguages[key].code}
lang={availableLanguages[key].code}
>
{availableLanguages[key].display}
</option>
))}
</Field>
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="discoverRegion" className="text-label">
<span>{intl.formatMessage(messages.discoverRegion)}</span>

View File

@@ -33,6 +33,7 @@ export interface UserSettings {
streamingRegion?: string;
originalLanguage?: string;
locale?: string;
mediaLocale?: string;
notificationTypes: Partial<NotificationAgentTypes>;
watchlistSyncMovies?: boolean;
watchlistSyncTv?: boolean;

View File

@@ -1464,6 +1464,7 @@
"components.UserProfile.UserSettings.UserGeneralSettings.generalsettings": "General Settings",
"components.UserProfile.UserSettings.UserGeneralSettings.languageDefault": "Default ({language})",
"components.UserProfile.UserSettings.UserGeneralSettings.localuser": "Local User",
"components.UserProfile.UserSettings.UserGeneralSettings.mediaLanguage": "Media Language",
"components.UserProfile.UserSettings.UserGeneralSettings.mediaServerUser": "{mediaServerName} User",
"components.UserProfile.UserSettings.UserGeneralSettings.movierequestlimit": "Movie Request Limit",
"components.UserProfile.UserSettings.UserGeneralSettings.originallanguage": "Discover Language",