diff --git a/.all-contributorsrc b/.all-contributorsrc index d19b5861..ceb13bd2 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -264,6 +264,34 @@ "code", "translation" ] + }, + { + "login": "chriscpritchard", + "name": "Chris Pritchard", + "avatar_url": "https://avatars1.githubusercontent.com/u/1839074?v=4", + "profile": "https://github.com/chriscpritchard", + "contributions": [ + "code", + "doc" + ] + }, + { + "login": "Tamberlox", + "name": "Tamberlox", + "avatar_url": "https://avatars3.githubusercontent.com/u/56069014?v=4", + "profile": "https://github.com/Tamberlox", + "contributions": [ + "translation" + ] + }, + { + "login": "hmnd", + "name": "David", + "avatar_url": "https://avatars.githubusercontent.com/u/12853597?v=4", + "profile": "https://hmnd.io", + "contributions": [ + "code" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/.eslintrc.js b/.eslintrc.js index a5518f73..c7286440 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -32,6 +32,7 @@ module.exports = { 'formatjs/no-offset': 'error', 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': ['error'], + 'jsx-a11y/no-onchange': 'off', }, overrides: [ { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8412f61..3bd56884 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,6 +56,10 @@ jobs: steps: - name: Checkout Code uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Switch to master branch + run: git checkout master - name: Prepare id: prepare diff --git a/.gitignore b/.gitignore index 968e5492..b13c9472 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,6 @@ dist # sqlite journal config/db/db.sqlite3-journal + +# VS Code +.vscode/launch.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 80a16c64..fb896f97 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -19,6 +19,9 @@ "stylelint.vscode-stylelint", - "bradlc.vscode-tailwindcss" + "bradlc.vscode-tailwindcss", + + // https://marketplace.visualstudio.com/items?itemName=heybourn.headwind + "heybourn.headwind" ] } diff --git a/README.md b/README.md index 5b757499..458448bb 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Language grade: JavaScript GitHub -All Contributors +All Contributors

@@ -46,7 +46,7 @@ ## Getting Started -Check out our documenation for steps on how to install and run Overseerr: +Check out our documentation for steps on how to install and run Overseerr: https://docs.overseerr.dev/getting-started/installation @@ -58,6 +58,7 @@ Currently, Overseerr is only distributed through Docker images. If you have Dock docker run -d \ -e LOG_LEVEL=info \ -e TZ=Asia/Tokyo \ + -e PROXY= -p 5055:5055 \ -v /path/to/appdata/config:/app/config \ --restart unless-stopped \ @@ -139,6 +140,11 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
hirenshah

📖
TheCatLady

💻 🌍 + +
Chris Pritchard

💻 📖 +
Tamberlox

🌍 +
David

💻 + diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 1685fc9b..4a25b678 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -18,4 +18,5 @@ ## Extending Overseerr -- [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md) +* [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md) +* [Fail2ban Filter](extending-overseerr/fail2ban.md) diff --git a/docs/extending-overseerr/fail2ban.md b/docs/extending-overseerr/fail2ban.md new file mode 100644 index 00000000..14ffd974 --- /dev/null +++ b/docs/extending-overseerr/fail2ban.md @@ -0,0 +1,14 @@ +# Fail2ban Filter + +{% hint style="warning" %} +If you are running Overseerr behind a reverse proxy, make sure that the **Enable Proxy Support** setting is **enabled**. +{% endhint %} + +To use Fail2ban with Overseerr, create a new file named `overseerr.local` in your Fail2ban `filter.d` directory with the following filter definition: + +``` +[Definition] +failregex = .*\[info\]\[Auth\]\: Failed login attempt.*"ip":"" +``` + +You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documetation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail. \ No newline at end of file diff --git a/docs/extending-overseerr/reverse-proxy-examples.md b/docs/extending-overseerr/reverse-proxy-examples.md index 160000fb..74c57ced 100644 --- a/docs/extending-overseerr/reverse-proxy-examples.md +++ b/docs/extending-overseerr/reverse-proxy-examples.md @@ -10,7 +10,7 @@ Base URLs cannot be configured in Overseerr. With this limitation, only subdomai A sample is bundled in SWAG. This page is still the only source of truth, so the sample is not guaranteed to be up to date. If you catch an inconsistency, report it to the linuxserver team, or do a pull-request against the proxy-confs repository to update the sample. -Rename the sample file `overseerr.subdomain.conf.sample` to `overseerr.subdomain.conf` in the `proxy-confs`folder, or create `overseerr.subdomain.conf` in the same folder with the example below. +Rename the sample file `overseerr.subdomain.conf.sample` to `overseerr.subdomain.conf` in the `proxy-confs`folder, or create `overseerr.subdomain.conf` in the same folder with the example below. Example Configuration: @@ -122,6 +122,8 @@ server { add_header X-Frame-Options "SAMEORIGIN" always; # Prevent Sniff Mimetype (X-Content-Type-Options) add_header X-Content-Type-Options "nosniff" always; + # Tell crawling bots to not index the site + add_header X-Robots-Tag "noindex, nofollow" always; access_log /var/log/nginx/overseerr.example.com-access.log; error_log /var/log/nginx/overseerr.example.com-error.log; @@ -131,4 +133,3 @@ server { } } ``` - diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 723afd03..a7757744 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -121,26 +121,49 @@ This version can break any moment. Be prepared to troubleshoot any issues that a {% tabs %} {% tab title="Gentoo" %} -Portage overlay [GitHub Repository](https://github.com/chriscpritchard/overseerr-overlay) +Portage overlay [GitHub Repository](https://github.com/chriscpritchard/overseerr-overlay). + +This is now included in the list of [Gentoo repositories](https://overlays.gentoo.org/), so can be easily enabled with `eselect repository` Efforts will be made to keep up to date with the latest releases, however, this cannot be guaranteed. -To enable using eselect repository, run: +**To enable:** +To enable using `eselect repository`, run: ```bash -eselect repository add overseerr-overlay git https://github.com/chriscpritchard/overseerr-overlay.git +eselect repository enable overseerr-overlay ``` +**To install:** Once complete, you can just run: ```bash emerge www-apps/overseerr ``` +**To install the development build:** +A live ebuild (`=www-apps/overseerr-9999`) is also available. To use this, you will need to modify accept_keywords for this package: + +```bash +emerge --autounmask --autounmask-write "=www-apps/overseerr-9999" +``` + +Once installed, you will not be notified of updates, so you can update with: + +```bash +emerge @live-rebuild +``` + +or use `app-portage/smart-live-rebuild` + +{% hint style="danger" %} +This version can break any moment. Be prepared to troubleshoot any issues that arise! +{% endhint %} + {% endtab %} {% tab title="Swizzin" %} -The installation is not implemented via docker, but barebones. The latest released version of overseerr will be used. +The installation is not implemented via Docker, but barebones. The latest release version of Overseerr will be used. Please see the [swizzin documentation](https://swizzin.ltd/applications/overseerr) for more information. To install, run the following: diff --git a/docs/support/faq.md b/docs/support/faq.md index b50111b8..e30cd38b 100644 --- a/docs/support/faq.md +++ b/docs/support/faq.md @@ -36,7 +36,7 @@ The most secure method, but also the most inconvenient, is to set up a VPN tunne ### Some media is missing from Overseerr that I know is in Plex! -**A:** Overseerr supports the new Plex Movie, Legacy Plex Movie, TheTVDB agent, and the TMDb agent. Please verify that your library is using one of the agents previously listed. If you are changing agents, a full metadata refresh will need to be performed. Caution, this can take a long time depending on how many items you have in your movie library. +**A:** Overseerr supports the new Plex Movie, legacy Plex Movie, TheTVDB, and TMDb agents. Please verify that your library is using one of the agents previously listed. If you are changing agents, a full metadata refresh will need to be performed. Caution, this can take a long time depending on how many items you have in your movie library. **Troubleshooting Steps:** @@ -55,8 +55,8 @@ Perform these steps to verify the media item has a guid Overseerr can match. **Examples:** -1. TMDB agent `guid="com.plexapp.agents.themoviedb://1705"` -2. The new Plex Movie agent `` +1. TMDb agent `guid="com.plexapp.agents.themoviedb://1705"` +2. New Plex Movie agent `` 3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"` 4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"` @@ -68,7 +68,7 @@ Perform these steps to verify the media item has a guid Overseerr can match. ### Why can't I see all my Plex users? -**A:** Navigate to your **User List** in Overseerr and click **Import Users From Plex** button. Don't forget to check the default user permissions in the **Settings -> General Settings** page beforehand. +**A:** Navigate to your **User List** in Overseerr and click **Import Users from Plex** button. Don't forget to check the default user permissions in the **Settings -> General Settings** page beforehand. ### Can I create local users in Overseerr? diff --git a/docs/using-overseerr/notifications/README.md b/docs/using-overseerr/notifications/README.md index 61323635..a4afbc00 100644 --- a/docs/using-overseerr/notifications/README.md +++ b/docs/using-overseerr/notifications/README.md @@ -11,7 +11,7 @@ Overseerr already supports a good number of notification agents, such as **Disco - Pushover - [Webhooks](./webhooks.md) -## Setting up Notifications +## Setting Up Notifications Configuring your notifications is _very simple_. First, you will need to visit the **Settings** page and click **Notifications** in the menu. This will present you with all of the currently available notification agents. Click on each one individually to configure them. diff --git a/docs/using-overseerr/notifications/webhooks.md b/docs/using-overseerr/notifications/webhooks.md index 9f563b4c..1a30511a 100644 --- a/docs/using-overseerr/notifications/webhooks.md +++ b/docs/using-overseerr/notifications/webhooks.md @@ -42,8 +42,8 @@ These variables are usually the target user of the notification. These variables are only included in media related notifications, such as requests. - `{{media_type}}` Media type. Either `movie` or `tv`. -- `{{media_tmdbid}}` Media's TMDB ID. -- `{{media_imdbid}}` Media's IMDB ID. +- `{{media_tmdbid}}` Media's TMDb ID. +- `{{media_imdbid}}` Media's IMDb ID. - `{{media_tvdbid}}` Media's TVDB ID. - `{{media_status}}` Media's availability status. (Ex. `AVAILABLE` or `PENDING`) - `{{media_status4k}}` Media's 4K availability status. (Ex. `AVAILABLE` or `PENDING`) diff --git a/overseerr-api.yml b/overseerr-api.yml index d77b596b..209b5ac2 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -62,6 +62,15 @@ components: applicationUrl: type: string example: https://os.example.com + trustProxy: + type: boolean + example: true + csrfProtection: + type: boolean + example: false + hideAvailable: + type: boolean + example: false defaultPermissions: type: number example: 32 @@ -107,6 +116,140 @@ components: - machineId - ip - port + PlexStatus: + type: object + properties: + settings: + $ref: '#/components/schemas/PlexSettings' + status: + type: number + example: 200 + message: + type: string + example: 'OK' + PlexConnection: + type: object + properties: + protocol: + type: string + example: 'https' + address: + type: string + example: '127.0.0.1' + port: + type: number + example: 32400 + uri: + type: string + example: 'https://127-0-0-1.2ab6ce1a093d465e910def96cf4e4799.plex.direct:32400' + local: + type: boolean + example: true + status: + type: number + example: 200 + message: + type: string + example: 'OK' + host: + type: string + example: '127-0-0-1.2ab6ce1a093d465e910def96cf4e4799.plex.direct' + required: + - protocol + - address + - port + - uri + - local + PlexDevice: + type: object + properties: + name: + type: string + example: 'My Plex Server' + product: + type: string + example: 'Plex Media Server' + productVersion: + type: string + example: '1.21' + platform: + type: string + example: 'Linux' + platformVersion: + type: string + example: 'default/linux/amd64/17.1/systemd' + device: + type: string + example: 'PC' + clientIdentifier: + type: string + example: '85a943ce-a0cc-4d2a-a4ec-f74f06e40feb' + createdAt: + type: string + example: '2021-01-01T00:00:00.000Z' + lastSeenAt: + type: string + example: '2021-01-01T00:00:00.000Z' + provides: + type: array + items: + type: string + example: 'server' + owned: + type: boolean + example: true + ownerID: + type: string + example: '12345' + home: + type: boolean + example: true + sourceTitle: + type: string + example: 'xyzabc' + accessToken: + type: string + example: 'supersecretaccesstoken' + publicAddress: + type: string + example: '127.0.0.1' + httpsRequired: + type: boolean + example: true + synced: + type: boolean + example: true + relay: + type: boolean + example: true + dnsRebindingProtection: + type: boolean + example: false + natLoopbackSupported: + type: boolean + example: false + publicAddressMatches: + type: boolean + example: false + presence: + type: boolean + example: true + connection: + type: array + items: + $ref: '#/components/schemas/PlexConnection' + required: + - name + - product + - productVersion + - platform + - device + - clientIdentifier + - createdAt + - lastSeenAt + - provides + - owned + - connection RadarrSettings: type: object properties: @@ -149,6 +292,15 @@ components: isDefault: type: boolean example: false + externalUrl: + type: string + example: http://radarr.example.com + syncEnabled: + type: boolean + example: false + preventSearch: + type: boolean + example: false required: - name - hostname @@ -212,6 +364,15 @@ components: isDefault: type: boolean example: false + externalUrl: + type: string + example: http://radarr.example.com + syncEnabled: + type: boolean + example: false + preventSearch: + type: boolean + example: false required: - name - hostname @@ -920,6 +1081,15 @@ components: type: number sound: type: string + NotificationSettings: + type: object + properties: + enabled: + type: boolean + example: true + autoapprovalEnabled: + type: boolean + example: false NotificationEmailSettings: type: object properties: @@ -1140,6 +1310,135 @@ components: type: array items: $ref: '#/components/schemas/MovieResult' + SonarrSeries: + type: object + properties: + title: + type: string + example: COVID-25 + sortTitle: + type: string + example: covid 25 + seasonCount: + type: number + example: 1 + status: + type: string + example: upcoming + overview: + type: string + example: The thread is picked up again by Marianne Schmidt which ... + network: + type: string + example: CBS + airTime: + type: string + example: 02:15 + images: + type: array + items: + type: object + properties: + coverType: + type: string + example: banner + url: + type: string + example: /sonarr/MediaCoverProxy/6467f05d9872726ad08cbf920e5fee4bf69198682260acab8eab5d3c2c958e92/5c8f116c6aa5c.jpg + remotePoster: + type: string + example: https://artworks.thetvdb.com/banners/posters/5c8f116129983.jpg + seasons: + type: array + items: + type: object + properties: + seasonNumber: + type: number + example: 1 + monitored: + type: boolean + example: true + year: + type: number + example: 2015 + path: + type: string + profileId: + type: number + languageProfileId: + type: number + seasonFolder: + type: boolean + monitored: + type: boolean + useSceneNumbering: + type: boolean + runtime: + type: number + tvdbId: + type: number + example: 12345 + tvRageId: + type: number + tvMazeId: + type: number + firstAired: + type: string + lastInfoSync: + type: string + nullable: true + seriesType: + type: string + cleanTitle: + type: string + imdbId: + type: string + titleSlug: + type: string + certification: + type: string + genres: + type: array + items: + type: string + tags: + type: array + items: + type: string + added: + type: string + ratings: + type: array + items: + type: object + properties: + votes: + type: number + value: + type: number + qualityProfileId: + type: number + id: + type: number + nullable: true + rootFolderPath: + type: string + nullable: true + addOptions: + type: array + items: + type: object + properties: + ignoreEpisodesWithFiles: + type: boolean + nullable: true + ignoreEpisodesWithoutFiles: + type: boolean + nullable: true + searchForMissingEpisodes: + type: boolean + nullable: true securitySchemes: cookieAuth: type: apiKey @@ -1153,8 +1452,8 @@ components: paths: /status: get: - summary: Return Overseerr version - description: Returns the current Overseerr version in JSON format + summary: Get Overseerr version + description: Returns the current Overseerr version in a JSON object. security: [] tags: - public @@ -1173,8 +1472,8 @@ paths: type: string /settings/main: get: - summary: Returns main settings - description: Retrieves all main settings in JSON format + summary: Get main settings + description: Retrieves all main settings in a JSON object. tags: - settings responses: @@ -1186,7 +1485,7 @@ paths: $ref: '#/components/schemas/MainSettings' post: summary: Update main settings - description: Update current main settings with provided values + description: Updates main settings with the provided values. tags: - settings requestBody: @@ -1204,8 +1503,8 @@ paths: $ref: '#/components/schemas/MainSettings' /settings/main/regenerate: get: - summary: Returns main settings with newly generated API Key - description: Retreives all main settings in JSON format with new API Key + summary: Get main settings with newly-generated API key + description: Returns main settings in a JSON object, using the new API key. tags: - settings responses: @@ -1217,8 +1516,8 @@ paths: $ref: '#/components/schemas/MainSettings' /settings/plex: get: - summary: Returns plex settings - description: Retrieves current Plex settings + summary: Get Plex settings + description: Retrieves current Plex settings. tags: - settings responses: @@ -1229,8 +1528,8 @@ paths: schema: $ref: '#/components/schemas/PlexSettings' post: - summary: Update plex settings - description: Update the current plex settings with provided values + summary: Update Plex settings + description: Updates Plex settings with the provided values. tags: - settings requestBody: @@ -1248,14 +1547,14 @@ paths: $ref: '#/components/schemas/PlexSettings' /settings/plex/library: get: - summary: Get a list of current plex libraries - description: Returns a list of plex libraries in a JSON array + summary: Get Plex libraries + description: Returns a list of Plex libraries in a JSON array. tags: - settings parameters: - in: query name: sync - description: Syncs the current libraries with the current plex server + description: Syncs the current libraries with the current Plex server schema: type: string nullable: true @@ -1278,8 +1577,8 @@ paths: $ref: '#/components/schemas/PlexLibrary' /settings/plex/sync: get: - summary: Start a full Plex Library sync - description: Runs a full plex library sync and returns the progress in a JSON array + summary: Start full Plex library sync + description: Runs a full Plex library sync and returns the progress in a JSON array. tags: - settings parameters: @@ -1295,7 +1594,7 @@ paths: example: false responses: '200': - description: Status of Plex Sync + description: Status of Plex sync content: application/json: schema: @@ -1316,10 +1615,25 @@ paths: type: array items: $ref: '#/components/schemas/PlexLibrary' + /settings/plex/devices/servers: + get: + summary: Gets the user's available plex servers + description: Returns a list of available plex servers and their connectivity state + tags: + - settings + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PlexDevice' /settings/radarr: get: - summary: Get all radarr settings - description: Returns all radarr settings in a JSON array + summary: Get Radarr settings + description: Returns all Radarr settings in a JSON array. tags: - settings responses: @@ -1332,8 +1646,8 @@ paths: items: $ref: '#/components/schemas/RadarrSettings' post: - summary: Create new radarr instance - description: Creates a new radarr instance from the request body + summary: Create Radarr instance + description: Creates a new Radarr instance from the request body. tags: - settings requestBody: @@ -1351,8 +1665,8 @@ paths: $ref: '#/components/schemas/RadarrSettings' /settings/radarr/test: post: - summary: Test radarr configuration - description: Test if the provided Radarr configuration values are valid. Returns profiles and root folders on success + summary: Test Radarr configuration + description: Tests if the Radarr configuration is valid. Returns profiles and root folders on success. tags: - settings requestBody: @@ -1395,8 +1709,8 @@ paths: $ref: '#/components/schemas/ServiceProfile' /settings/radarr/{radarrId}: put: - summary: Update existing radarr instance - description: Updates an existing radarr instance with values from request body + summary: Update Radarr instance + description: Updates an existing Radarr instance with the provided values. tags: - settings parameters: @@ -1420,8 +1734,8 @@ paths: schema: $ref: '#/components/schemas/RadarrSettings' delete: - summary: Delete existing radarr instance - description: Deletes an existing radarr instance based on id parameter + summary: Delete Radarr instance + description: Deletes an existing Radarr instance based on the radarrId parameter. tags: - settings parameters: @@ -1430,7 +1744,7 @@ paths: required: true schema: type: integer - description: Radarr Instance ID + description: Radarr instance ID responses: '200': description: 'Radarr instance updated' @@ -1440,8 +1754,8 @@ paths: $ref: '#/components/schemas/RadarrSettings' /settings/radarr/{radarrId}/profiles: get: - summary: Retrieve available profiles for the Radarr instance - description: Returns an array of profile available on the Radarr server instance in JSON format + summary: Get available Radarr profiles + description: Returns a list of profiles available on the Radarr server instance in a JSON array. tags: - settings parameters: @@ -1450,7 +1764,7 @@ paths: required: true schema: type: integer - description: Radarr Instance ID + description: Radarr instance ID responses: '200': description: Returned list of profiles @@ -1462,8 +1776,8 @@ paths: $ref: '#/components/schemas/ServiceProfile' /settings/sonarr: get: - summary: Get all sonarr settings - description: Returns all sonarr settings in a JSON array + summary: Get Sonarr settings + description: Returns all Sonarr settings in a JSON array. tags: - settings responses: @@ -1476,8 +1790,8 @@ paths: items: $ref: '#/components/schemas/SonarrSettings' post: - summary: Create new Sonarr instance - description: Creates a new Sonarr instance from the request body + summary: Create Sonarr instance + description: Creates a new Sonarr instance from the request body. tags: - settings requestBody: @@ -1496,7 +1810,7 @@ paths: /settings/sonarr/test: post: summary: Test Sonarr configuration - description: Test if the provided Sonarr configuration values are 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: @@ -1539,8 +1853,8 @@ paths: $ref: '#/components/schemas/ServiceProfile' /settings/sonarr/{sonarrId}: put: - summary: Update existing sonarr instance - description: Updates an existing sonarr instance with values from request body + summary: Update Sonarr instance + description: Updates an existing Sonarr instance with the provided values. tags: - settings parameters: @@ -1564,8 +1878,8 @@ paths: schema: $ref: '#/components/schemas/SonarrSettings' delete: - summary: Delete existing sonarr instance - description: Deletes an existing sonarr instance based on id parameter + summary: Delete Sonarr instance + description: Deletes an existing Sonarr instance based on the sonarrId parameter. tags: - settings parameters: @@ -1574,7 +1888,7 @@ paths: required: true schema: type: integer - description: Sonarr Instance ID + description: Sonarr instance ID responses: '200': description: 'Sonarr instance updated' @@ -1584,35 +1898,35 @@ paths: $ref: '#/components/schemas/SonarrSettings' /settings/public: get: - summary: Returns public settings + summary: Get public settings security: [] - description: Returns settings that are not protected or sensitive. Mainly used to determine if the app 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: '200': - description: Public Settings returned + description: Public settings returned content: application/json: schema: $ref: '#/components/schemas/PublicSettings' /settings/initialize: get: - summary: Set the application as initialized - description: Sets the app as initialized and allows the user to navigate to pages other than the setup page + summary: Initialize application + description: Sets the app as initialized, allowing the user to navigate to pages other than the setup page. tags: - settings responses: '200': - description: Public Settings returned + description: Public settings returned content: application/json: schema: $ref: '#/components/schemas/PublicSettings' /settings/jobs: get: - summary: Returns list of scheduled jobs - description: Returns list of all scheduled jobs and details about their next execution time + summary: Get scheduled jobs + description: Returns list of all scheduled jobs and details about their next execution time in a JSON array. tags: - settings responses: @@ -1625,16 +1939,126 @@ paths: items: type: object properties: + id: + type: string + example: job-name name: type: string example: A Job Name + type: + type: string + enum: [process, command] nextExecutionTime: type: string example: '2020-09-02T05:02:23.000Z' + running: + type: boolean + example: false + /settings/jobs/{jobId}/run: + get: + summary: Invoke a specific job + description: Invokes a specific job to run. Will return the new job status in JSON format. + tags: + - settings + parameters: + - in: path + name: jobId + required: true + schema: + type: string + responses: + '200': + description: Invoked job returned + content: + application/json: + schema: + type: object + properties: + id: + type: string + example: job-name + type: + type: string + enum: [process, command] + name: + type: string + example: A Job Name + nextExecutionTime: + type: string + example: '2020-09-02T05:02:23.000Z' + running: + type: boolean + example: false + /settings/jobs/{jobId}/cancel: + get: + summary: Cancel a specific job + description: Cancels a specific job. Will return the new job status in JSON format. + tags: + - settings + parameters: + - in: path + name: jobId + required: true + schema: + type: string + responses: + '200': + description: Cancelled job returned + content: + application/json: + schema: + type: object + properties: + id: + type: string + example: job-name + type: + type: string + enum: [process, command] + name: + type: string + example: A Job Name + nextExecutionTime: + type: string + example: '2020-09-02T05:02:23.000Z' + running: + type: boolean + example: false + /settings/notifications: + get: + summary: Return notification settings + description: Returns current notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned settings + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationSettings' + post: + summary: Update notification settings + description: Updates notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationSettings' /settings/notifications/email: get: - summary: Return current email notification settings - description: Returns current email notification settings in JSON format + summary: Get email notification settings + description: Returns current email notification settings in a JSON object. tags: - settings responses: @@ -1646,7 +2070,7 @@ paths: $ref: '#/components/schemas/NotificationEmailSettings' post: summary: Update email notification settings - description: Update current email notification settings with provided values + description: Updates email notification settings with provided values tags: - settings requestBody: @@ -1664,8 +2088,8 @@ paths: $ref: '#/components/schemas/NotificationEmailSettings' /settings/notifications/email/test: post: - summary: Test the provided email settings - description: Sends a test notification to the email agent + summary: Test email settings + description: Sends a test notification to the email agent. tags: - settings requestBody: @@ -1679,20 +2103,20 @@ paths: description: Test notification attempted /settings/notifications/discord: get: - summary: Return current discord notification settings - description: Returns current discord notification settings in JSON format + summary: Get Discord notification settings + description: Returns current Discord notification settings in a JSON object. tags: - settings responses: '200': - description: Returned discord settings + description: Returned Discord settings content: application/json: schema: $ref: '#/components/schemas/DiscordSettings' post: - summary: Update discord notification settings - description: Update current discord notification settings with provided values + summary: Update Discord notification settings + description: Updates Discord notification settings with the provided values. tags: - settings requestBody: @@ -1710,8 +2134,8 @@ paths: $ref: '#/components/schemas/DiscordSettings' /settings/notifications/discord/test: post: - summary: Test the provided discord settings - description: Sends a test notification to the discord agent + summary: Test Discord settings + description: Sends a test notification to the Discord agent. tags: - settings requestBody: @@ -1725,20 +2149,20 @@ paths: description: Test notification attempted /settings/notifications/telegram: get: - summary: Return current telegram notification settings - description: Returns current telegram notification settings in JSON format + summary: Get Telegram notification settings + description: Returns current Telegram notification settings in a JSON object. tags: - settings responses: '200': - description: Returned telegram settings + description: Returned Telegram settings content: application/json: schema: $ref: '#/components/schemas/TelegramSettings' post: - summary: Update telegram notification settings - description: Update current telegram notification settings with provided values + summary: Update Telegram notification settings + description: Update Telegram notification settings with the provided values. tags: - settings requestBody: @@ -1756,8 +2180,8 @@ paths: $ref: '#/components/schemas/TelegramSettings' /settings/notifications/telegram/test: post: - summary: Test the provided telegram settings - description: Sends a test notification to the telegram agent + summary: Test Telegram settings + description: Sends a test notification to the Telegram agent. tags: - settings requestBody: @@ -1771,20 +2195,20 @@ paths: description: Test notification attempted /settings/notifications/pushover: get: - summary: Return current pushover notification settings - description: Returns current pushover notification settings in JSON format + summary: Get Pushover notification settings + description: Returns current Pushover notification settings in a JSON object. tags: - settings responses: '200': - description: Returned pushover settings + description: Returned Pushover settings content: application/json: schema: $ref: '#/components/schemas/PushoverSettings' post: summary: Update pushover notification settings - description: Update current pushover notification settings with provided values + description: Update Pushover notification settings with the provided values. tags: - settings requestBody: @@ -1802,8 +2226,8 @@ paths: $ref: '#/components/schemas/PushoverSettings' /settings/notifications/pushover/test: post: - summary: Test the provided pushover settings - description: Sends a test notification to the pushover agent + summary: Test Pushover settings + description: Sends a test notification to the Pushover agent. tags: - settings requestBody: @@ -1817,8 +2241,8 @@ paths: description: Test notification attempted /settings/notifications/slack: get: - summary: Return current slack notification settings - description: Returns current slack notification settings in JSON format + summary: Get Slack notification settings + description: Returns current Slack notification settings in a JSON object. tags: - settings responses: @@ -1829,8 +2253,8 @@ paths: schema: $ref: '#/components/schemas/SlackSettings' post: - summary: Update slack notification settings - description: Update current slack notification settings with provided values + summary: Update Slack notification settings + description: Updates Slack notification settings with the provided values. tags: - settings requestBody: @@ -1848,8 +2272,8 @@ paths: $ref: '#/components/schemas/SlackSettings' /settings/notifications/slack/test: post: - summary: Test the provided slack settings - description: Sends a test notification to the slack agent + summary: Test Slack settings + description: Sends a test notification to the Slack agent. tags: - settings requestBody: @@ -1863,8 +2287,8 @@ paths: description: Test notification attempted /settings/notifications/webhook: get: - summary: Return current webhook notification settings - description: Returns current webhook notification settings in JSON format + summary: Get webhook notification settings + description: Returns current webhook notification settings in a JSON object. tags: - settings responses: @@ -1876,7 +2300,7 @@ paths: $ref: '#/components/schemas/WebhookSettings' post: summary: Update webhook notification settings - description: Update current webhook notification settings with provided values + description: Updates webhook notification settings with the provided values. tags: - settings requestBody: @@ -1894,8 +2318,8 @@ paths: $ref: '#/components/schemas/WebhookSettings' /settings/notifications/webhook/test: post: - summary: Test the provided slack settings - description: Sends a test notification to the slack agent + summary: Test webhook settings + description: Sends a test notification to the webhook agent. tags: - settings requestBody: @@ -1909,8 +2333,8 @@ paths: description: Test notification attempted /settings/about: get: - summary: Return current about stats - description: Returns current server stats in JSON format + summary: Get server stats + description: Returns current server stats in a JSON object. tags: - settings responses: @@ -1936,22 +2360,22 @@ paths: example: Asia/Tokyo /auth/me: get: - summary: Returns the currently logged in user - description: Returns the currently logged in user + summary: Get logged-in user + description: Returns the currently logged-in user. tags: - auth - users responses: '200': - description: Object containing the logged in user in JSON + description: Object containing the logged-in user in JSON content: application/json: schema: $ref: '#/components/schemas/User' /auth/login: post: - summary: Login using a plex auth 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. + 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. security: [] tags: - auth @@ -1975,7 +2399,7 @@ paths: - authToken /auth/local: post: - summary: Login using a local account + 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. security: [] tags: @@ -2003,8 +2427,8 @@ paths: - password /auth/logout: get: - summary: Logout and clear session cookie - description: This endpoint will completely clear the session cookie and associated values, logging out the user + summary: Sign out and clear session cookie + description: Completely clear the session cookie and associated values, effectively signing the user out. tags: - auth responses: @@ -2020,8 +2444,8 @@ paths: example: 'ok' /user: get: - summary: Returns a list of all users - description: Requests all users and returns them in a large array + summary: Get all users + description: Returns all users in a JSON array. tags: - users responses: @@ -2034,13 +2458,9 @@ paths: items: $ref: '#/components/schemas/User' post: - summary: Create a new user + summary: Create new user description: | - Creates a new user. Should under normal circumstances never be called as you will not have a valid authToken to provide for the user. - - In the future when Plex auth is not required, this will be used to create accounts. - - Requires the `MANAGE_USERS` permission. + Creates a new user. Requires the `MANAGE_USERS` permission. tags: - users requestBody: @@ -2051,14 +2471,44 @@ paths: $ref: '#/components/schemas/User' responses: '201': - description: The created user in JSON + description: The created user content: application/json: schema: $ref: '#/components/schemas/User' + put: + summary: Update batch of users + description: | + Update users with given IDs with provided values in request `body.settings`. You cannot update users' plex tokens through this request. + + Requires the `MANAGE_USERS` permission. + tags: + - users + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + ids: + type: array + items: + type: integer + permissions: + type: integer + responses: + '200': + description: Successfully updated user details + content: + application/json: + schema: + type: array + $ref: '#/components/schemas/User' + /user/import-from-plex: post: - summary: Imports all users from Plex + summary: Import all users from Plex description: | Requests users from the Plex Server and creates a new user for each of them @@ -2077,9 +2527,9 @@ paths: /user/{userId}: get: - summary: Retrieve a user by ID + summary: Get user by ID description: | - Retrieve user details in JSON format. Requires the `MANAGE_USERS` permission. + Retrieves user details in a JSON object.. Requires the `MANAGE_USERS` permission. tags: - users parameters: @@ -2098,7 +2548,7 @@ paths: put: summary: Update a user by user ID description: | - Update a user with provided values in request body. You cannot update a users plex token through this request. + Update a user with the provided values. You cannot update a user's Plex token through this request. Requires the `MANAGE_USERS` permission. tags: @@ -2123,8 +2573,8 @@ paths: schema: $ref: '#/components/schemas/User' delete: - summary: Delete a user by user ID - description: Deletes a user by provided user ID. Requires the `MANAGE_USERS` permission. + summary: Delete user by ID + description: Deletes the user with the provided userId. Requires the `MANAGE_USERS` permission. tags: - users parameters: @@ -2142,8 +2592,8 @@ paths: $ref: '#/components/schemas/User' /search: get: - summary: Search for movies/tv shows/people - description: Returns a list of movies/tv shows/people in JSON format + summary: Search for movies, TV shows, or people + description: Returns a list of movies, TV shows, or people a JSON object. tags: - search parameters: @@ -2191,7 +2641,7 @@ paths: /discover/movies: get: summary: Discover movies - description: Returns a list of movies in JSON format + description: Returns a list of movies in a JSON object. tags: - search parameters: @@ -2229,8 +2679,8 @@ paths: $ref: '#/components/schemas/MovieResult' /discover/movies/upcoming: get: - summary: Upcoming Movies - description: Returns a list of movies in JSON format + summary: Upcoming movies + description: Returns a list of movies in a JSON object. tags: - search parameters: @@ -2269,7 +2719,7 @@ paths: /discover/tv: get: summary: Discover TV shows - description: Returns a list of tv shows in JSON format + description: Returns a list of TV shows in a JSON object. tags: - search parameters: @@ -2307,8 +2757,8 @@ paths: $ref: '#/components/schemas/TvResult' /discover/trending: get: - summary: Trending TV and Movies - description: Returns a list of movie/tv shows in JSON format + summary: Trending movies and TV + description: Returns a list of movies and TV shows in a JSON object. tags: - search parameters: @@ -2349,8 +2799,8 @@ paths: - $ref: '#/components/schemas/PersonResult' /discover/keyword/{keywordId}/movies: get: - summary: Request list of movies from keyword - description: Returns list of movies based on provided keyword ID in JSON format + summary: Get movies from keyword + description: Returns list of movies based on the provided keyword ID a JSON object. tags: - search parameters: @@ -2396,7 +2846,7 @@ paths: get: summary: Get all requests description: | - Returns all requests if the user has the `ADMIN` or `MANAGE_REQUESTS` permissions. Otherwise, only the logged in users requests are returned. + Returns all requests if the user has the `ADMIN` or `MANAGE_REQUESTS` permissions. Otherwise, only the logged-in user's requests are returned. tags: - request parameters: @@ -2439,9 +2889,9 @@ paths: items: $ref: '#/components/schemas/MediaRequest' post: - summary: Create a new request + summary: Create new request description: | - Creates a new request with the provided media id and type. The `REQUEST` permission is required. + Creates a new request with the provided media ID and type. The `REQUEST` permission is required. If the user has the `ADMIN` or `AUTO_APPROVE` permissions, their request will be auomatically approved. tags: @@ -2488,7 +2938,7 @@ paths: $ref: '#/components/schemas/MediaRequest' /request/count: get: - summary: Returns request counts + summary: Gets request counts description: | Returns the number of pending and approved requests. tags: @@ -2512,8 +2962,8 @@ paths: - approved /request/{requestId}: get: - summary: Requests a specific MediaRequest - description: Returns a MediaRequest in JSON format + summary: Get MediaRequest + description: Returns a specific MediaRequest in a JSON object. tags: - request parameters: @@ -2532,8 +2982,8 @@ paths: schema: $ref: '#/components/schemas/MediaRequest' put: - summary: Update a specific MediaRequest - description: Updats a specific media request and returns the request in JSON format. Requires the `MANAGE_REQUESTS` permission. + summary: Update MediaRequest + description: Updates a specific media request and returns the request in a JSON object.. Requires the `MANAGE_REQUESTS` permission. tags: - request parameters: @@ -2552,8 +3002,8 @@ paths: schema: $ref: '#/components/schemas/MediaRequest' delete: - summary: Delete a request - description: Removes a request. If the user has the `MANAGE_REQUESTS` permission, then any request can be removed. Otherwise, only pending requests can be removed. + 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. tags: - request parameters: @@ -2569,11 +3019,11 @@ paths: description: Succesfully removed request /request/{requestId}/retry: post: - summary: Retry a failed request + summary: Retry failed request description: | - Retries a request by resending requests to Sonarr or Radarr + Retries a request by resending requests to Sonarr or Radarr. - Requires the `MANAGE_REQUESTS` permission or `ADMIN` + Requires the `MANAGE_REQUESTS` permission or `ADMIN`. tags: - request parameters: @@ -2595,9 +3045,9 @@ paths: get: summary: Update a requests status description: | - Updates a requests status to approved or declined. Also returns the request in JSON format + Updates a requests status to approved or declined. Also returns the request in a JSON object. - Requires the `MANAGE_REQUESTS` permission or `ADMIN` + Requires the `MANAGE_REQUESTS` permission or `ADMIN`. tags: - request parameters: @@ -2624,8 +3074,8 @@ paths: $ref: '#/components/schemas/MediaRequest' /movie/{movieId}: get: - summary: Request movie details - description: Returns back full movie details in JSON format + summary: Get movie details + description: Returns full movie details in a JSON object. tags: - movies parameters: @@ -2649,8 +3099,8 @@ paths: $ref: '#/components/schemas/MovieDetails' /movie/{movieId}/recommendations: get: - summary: Request recommended movies - description: Returns list of recommended movies based on provided movie ID in JSON format + summary: Get recommended movies + description: Returns list of recommended movies based on provided movie ID in a JSON object. tags: - movies parameters: @@ -2694,8 +3144,8 @@ paths: $ref: '#/components/schemas/MovieResult' /movie/{movieId}/similar: get: - summary: Request similar movies - description: Returns list of similar movies based on provided movie ID in JSON format + summary: Get similar movies + description: Returns list of similar movies based on the provided movieId in a JSON object. tags: - movies parameters: @@ -2739,8 +3189,8 @@ paths: $ref: '#/components/schemas/MovieResult' /movie/{movieId}/ratings: get: - summary: Get ratings for the provided movie id - description: Returns ratings based on provided movie ID in JSON format + summary: Get movie ratings + description: Returns ratings based on the provided movieId in a JSON object. tags: - movies parameters: @@ -2781,8 +3231,8 @@ paths: enum: ['Spilled', 'Upright'] /tv/{tvId}: get: - summary: Request tv details - description: Returns back full tv details in JSON format + summary: Get TV details + description: Returns full TV details in a JSON object. tags: - tv parameters: @@ -2806,8 +3256,8 @@ paths: $ref: '#/components/schemas/TvDetails' /tv/{tvId}/season/{seasonId}: get: - summary: Return season details with episode list - description: Returns back season details with a list of episodes + summary: Get season details and episode list + description: Returns season details with a list of episodes in a JSON object. tags: - tv parameters: @@ -2837,8 +3287,8 @@ paths: $ref: '#/components/schemas/Season' /tv/{tvId}/recommendations: get: - summary: Request recommended tv series - description: Returns list of recommended tv series based on provided tv ID in JSON format + summary: Get recommended TV series + description: Returns list of recommended TV series based on the provided tvId in a JSON object. tags: - tv parameters: @@ -2861,7 +3311,7 @@ paths: example: en responses: '200': - description: List of tv series + description: List of TV series content: application/json: schema: @@ -2882,8 +3332,8 @@ paths: $ref: '#/components/schemas/TvResult' /tv/{tvId}/similar: get: - summary: Request similar tv series - description: Returns list of similar tv series based on provided movie ID in JSON format + summary: Get similar TV series + description: Returns list of similar TV series based on the provided tvId in a JSON object. tags: - tv parameters: @@ -2906,7 +3356,7 @@ paths: example: en responses: '200': - description: List of tv series + description: List of TV series content: application/json: schema: @@ -2927,8 +3377,8 @@ paths: $ref: '#/components/schemas/TvResult' /tv/{tvId}/ratings: get: - summary: Get ratings for the provided tv id - description: Returns ratings based on provided tv ID in JSON format + summary: Get TV ratings + description: Returns ratings based on provided tvId in a JSON object. tags: - tv parameters: @@ -2963,8 +3413,8 @@ paths: enum: ['Rotten', 'Fresh'] /person/{personId}: get: - summary: Request person details - description: Returns details of the person based on provided person ID in JSON format + summary: Get person details + description: Returns person details based on provided personId in a JSON object. tags: - person parameters: @@ -2989,8 +3439,8 @@ paths: /person/{personId}/combined_credits: get: - summary: Request combined credits of person - description: Returns the combined credits of the person based on the provided person ID in JSON format + summary: Get combined credits + description: Returns the person's combined credits based on the provided personId in a JSON object. tags: - person parameters: @@ -3007,7 +3457,7 @@ paths: example: en responses: '200': - description: Returned combined credts + description: Returned combined credits content: application/json: schema: @@ -3025,8 +3475,8 @@ paths: type: number /media: get: - summary: Return all media - description: Returns all media (can be filtered and limited) in JSON format + summary: Return media + description: Returns all media (can be filtered and limited) in a JSON object. tags: - media parameters: @@ -3047,7 +3497,7 @@ paths: schema: type: string nullable: true - enum: [all, available, partial, processing, pending] + enum: [all, available, partial, allavailable, processing, pending] - in: query name: sort schema: @@ -3070,7 +3520,7 @@ paths: $ref: '#/components/schemas/MediaInfo' /media/{mediaId}: delete: - summary: Delete a media item + summary: Delete media item description: Removes a media item. The `MANAGE_REQUESTS` permission is required to perform this action. tags: - media @@ -3085,10 +3535,45 @@ paths: responses: '204': description: Succesfully removed media item + /media/{mediaId}/{status}: + get: + summary: Update media status + description: Updates a medias status and returns the media in JSON format + tags: + - media + parameters: + - in: path + name: mediaId + description: Media ID + required: true + example: 1 + schema: + type: string + - in: path + name: status + description: New status + required: true + example: available + schema: + type: string + enum: [available, partial, processing, pending, unknown] + - in: query + name: is4k + description: 4K Status + example: false + schema: + type: boolean + responses: + '200': + description: Returned media + content: + application/json: + schema: + $ref: '#/components/schemas/MediaInfo' /collection/{collectionId}: get: - summary: Request collection details - description: Returns back full collection details in JSON format + summary: Get collection details + description: Returns full collection details in a JSON object. tags: - collection parameters: @@ -3112,8 +3597,8 @@ paths: $ref: '#/components/schemas/Collection' /service/radarr: get: - summary: Returns non-sensitive radarr server list - description: Returns a list of radarr servers, both ID and name in JSON format + summary: Get non-sensitive Radarr server list + description: Returns a list of Radarr server IDs and names in a JSON object. tags: - service responses: @@ -3127,8 +3612,8 @@ paths: $ref: '#/components/schemas/RadarrSettings' /service/radarr/{radarrId}: get: - summary: Returns radarr server quality profiles and root folders - description: Returns a radarr server quality profile and root folder details in JSON format + 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. tags: - service parameters: @@ -3152,8 +3637,8 @@ paths: $ref: '#/components/schemas/ServiceProfile' /service/sonarr: get: - summary: Returns non-sensitive sonarr server list - description: Returns a list of sonarr servers, both ID and name in JSON format + summary: Get non-sensitive Sonarr server list + description: Returns a list of Sonarr server IDs and names in a JSON object. tags: - service responses: @@ -3167,8 +3652,8 @@ paths: $ref: '#/components/schemas/SonarrSettings' /service/sonarr/{sonarrId}: get: - summary: Returns sonarr server quality profiles and root folders - description: Returns a sonarr server quality profile and root folder details in JSON format + 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. tags: - service parameters: @@ -3190,6 +3675,28 @@ paths: $ref: '#/components/schemas/SonarrSettings' profiles: $ref: '#/components/schemas/ServiceProfile' + /service/sonarr/lookup/{tmdbId}: + get: + summary: Get series from Sonarr + description: Returns a list of series returned by searching for the name in Sonarr. + tags: + - service + parameters: + - in: path + name: tmdbId + required: true + schema: + type: number + example: 0 + responses: + '200': + description: Request successful + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SonarrSeries' security: - cookieAuth: [] diff --git a/package.json b/package.json index 8e091f2c..49a3581c 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ }, "license": "MIT", "dependencies": { + "@supercharge/request-ip": "^1.1.2", "@svgr/webpack": "^5.5.0", "ace-builds": "^1.4.12", "axios": "^0.21.1", @@ -25,6 +26,7 @@ "bowser": "^2.11.0", "connect-typeorm": "^1.1.4", "cookie-parser": "^1.4.5", + "csurf": "^1.11.0", "email-templates": "^8.0.3", "express": "^4.17.1", "express-openapi-validator": "^4.10.8", @@ -36,11 +38,12 @@ "next": "10.0.3", "node-schedule": "^1.3.2", "nodemailer": "^6.4.17", - "nookies": "^2.5.1", + "nookies": "^2.5.2", "plex-api": "^5.3.1", "pug": "^3.0.0", "react": "17.0.1", "react-ace": "^9.2.1", + "react-animate-height": "^2.0.23", "react-dom": "17.0.1", "react-intersection-observer": "^8.31.0", "react-intl": "^5.10.16", @@ -77,11 +80,12 @@ "@types/bcrypt": "^3.0.0", "@types/body-parser": "^1.19.0", "@types/cookie-parser": "^1.4.2", + "@types/csurf": "^1.11.0", "@types/email-templates": "^8.0.0", "@types/express": "^4.17.11", "@types/express-session": "^1.17.0", - "@types/lodash": "^4.14.167", - "@types/node": "^14.14.21", + "@types/lodash": "^4.14.168", + "@types/node": "^14.14.22", "@types/node-schedule": "^1.3.1", "@types/nodemailer": "^6.4.0", "@types/react": "^17.0.0", @@ -91,11 +95,11 @@ "@types/secure-random-password": "^0.2.0", "@types/swagger-ui-express": "^4.1.2", "@types/uuid": "^8.3.0", - "@types/xml2js": "^0.4.7", + "@types/xml2js": "^0.4.8", "@types/yamljs": "^0.2.31", "@types/yup": "^0.29.11", - "@typescript-eslint/eslint-plugin": "^4.13.0", - "@typescript-eslint/parser": "^4.13.0", + "@typescript-eslint/eslint-plugin": "^4.14.0", + "@typescript-eslint/parser": "^4.14.0", "autoprefixer": "^9", "babel-plugin-react-intl": "^8.2.25", "babel-plugin-react-intl-auto": "^3.3.0", @@ -103,7 +107,7 @@ "copyfiles": "^2.4.1", "cz-conventional-changelog": "^3.3.0", "eslint": "^7.18.0", - "eslint-config-prettier": "^7.1.0", + "eslint-config-prettier": "^7.2.0", "eslint-plugin-formatjs": "^2.10.3", "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-prettier": "^3.3.1", @@ -116,12 +120,15 @@ "postcss": "^7", "postcss-preset-env": "^6.7.0", "prettier": "^2.2.1", - "semantic-release": "^17.3.3", + "semantic-release": "^17.3.6", "semantic-release-docker": "^2.2.0", "tailwindcss": "npm:@tailwindcss/postcss7-compat", "ts-node": "^9.1.1", "typescript": "^4.1.3" }, + "resolutions": { + "sqlite3/node-gyp": "^5.1.0" + }, "config": { "commitizen": { "path": "./node_modules/cz-conventional-changelog" diff --git a/public/android-chrome-192x192.png b/public/android-chrome-192x192.png index c076d98f..692f01a8 100644 Binary files a/public/android-chrome-192x192.png and b/public/android-chrome-192x192.png differ diff --git a/public/android-chrome-512x512.png b/public/android-chrome-512x512.png index 8d57aae3..34d1f9e1 100644 Binary files a/public/android-chrome-512x512.png and b/public/android-chrome-512x512.png differ diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index 34715194..34c700e2 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/favicon-32x32.png b/public/favicon-32x32.png index cd6b494d..8cd58e54 100644 Binary files a/public/favicon-32x32.png and b/public/favicon-32x32.png differ diff --git a/public/images/overseerr_poster_not_found.png b/public/images/overseerr_poster_not_found.png new file mode 100644 index 00000000..2f5bc203 Binary files /dev/null and b/public/images/overseerr_poster_not_found.png differ diff --git a/public/images/overseerr_poster_not_found_logo_center.png b/public/images/overseerr_poster_not_found_logo_center.png new file mode 100644 index 00000000..2ecd84b0 Binary files /dev/null and b/public/images/overseerr_poster_not_found_logo_center.png differ diff --git a/public/images/overseerr_poster_not_found_logo_top.png b/public/images/overseerr_poster_not_found_logo_top.png new file mode 100644 index 00000000..a74b096e Binary files /dev/null and b/public/images/overseerr_poster_not_found_logo_top.png differ diff --git a/public/images/radarr_logo.png b/public/images/radarr_logo.png index d6165cf5..482b742e 100644 Binary files a/public/images/radarr_logo.png and b/public/images/radarr_logo.png differ diff --git a/public/images/rotate2.jpg b/public/images/rotate2.jpg index d9a20611..b819b38f 100644 Binary files a/public/images/rotate2.jpg and b/public/images/rotate2.jpg differ diff --git a/public/images/rotate3.jpg b/public/images/rotate3.jpg index 94ca5a3f..a8b181ba 100644 Binary files a/public/images/rotate3.jpg and b/public/images/rotate3.jpg differ diff --git a/public/logo.png b/public/logo.png index 6a213b8e..7b61ea02 100644 Binary files a/public/logo.png and b/public/logo.png differ diff --git a/public/os_logo_square.png b/public/os_logo_square.png index 0fd6807c..9b9d6c53 100644 Binary files a/public/os_logo_square.png and b/public/os_logo_square.png differ diff --git a/public/preview.jpg b/public/preview.jpg index bbf562c6..8abdaa1e 100644 Binary files a/public/preview.jpg and b/public/preview.jpg differ diff --git a/server/api/animelist.ts b/server/api/animelist.ts index 428684bc..44205942 100644 --- a/server/api/animelist.ts +++ b/server/api/animelist.ts @@ -12,7 +12,7 @@ const LOCAL_PATH = path.join(__dirname, '../../config/anime-list.xml'); const mappingRegexp = new RegExp(/;[0-9]+-([0-9]+)/g); -// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to tvdb/tmdb IDs +// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to TVDB/TMDb IDs // https://github.com/Anime-Lists/anime-lists/ interface AnimeMapping { @@ -125,7 +125,7 @@ class AnimeListMapping { } } else { // some movies do not have mapping-list, so map episode 1,2,3,..to movies - // movies must have imdbid or tmdbid + // movies must have imdbId or tmdbId const hasImdb = imdbIds.length > 1 || imdbIds[0] !== undefined; if ((hasImdb || tmdbId) && anime.$.defaulttvdbseason === '0') { if (!this.specials[tvdbId]) { diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index 43487c21..b87dc342 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -1,5 +1,5 @@ import NodePlexAPI from 'plex-api'; -import { getSettings } from '../lib/settings'; +import { getSettings, PlexSettings } from '../lib/settings'; export interface PlexLibraryItem { ratingKey: string; @@ -80,13 +80,26 @@ interface PlexMetadataResponse { class PlexAPI { private plexClient: NodePlexAPI; - constructor({ plexToken }: { plexToken?: string }) { + constructor({ + plexToken, + plexSettings, + timeout, + }: { + plexToken?: string; + plexSettings?: PlexSettings; + timeout?: number; + }) { const settings = getSettings(); + let settingsPlex: PlexSettings | undefined; + plexSettings + ? (settingsPlex = plexSettings) + : (settingsPlex = getSettings().plex); this.plexClient = new NodePlexAPI({ - hostname: settings.plex.ip, - port: settings.plex.port, - https: settings.plex.useSsl, + hostname: settingsPlex.ip, + port: settingsPlex.port, + https: settingsPlex.useSsl, + timeout: timeout, token: plexToken, authenticator: { authenticate: ( @@ -111,6 +124,7 @@ class PlexAPI { }); } + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types public async getStatus() { return await this.plexClient.query('/'); } diff --git a/server/api/plextv.ts b/server/api/plextv.ts index e3e40c73..0182c27c 100644 --- a/server/api/plextv.ts +++ b/server/api/plextv.ts @@ -1,5 +1,6 @@ import axios, { AxiosInstance } from 'axios'; import xml2js from 'xml2js'; +import { PlexDevice } from '../interfaces/api/plexInterfaces'; import { getSettings } from '../lib/settings'; import logger from '../logger'; @@ -29,6 +30,45 @@ interface PlexUser { entitlements: string[]; } +interface ConnectionResponse { + $: { + protocol: string; + address: string; + port: string; + uri: string; + local: string; + }; +} + +interface DeviceResponse { + $: { + name: string; + product: string; + productVersion: string; + platform: string; + platformVersion: string; + device: string; + clientIdentifier: string; + createdAt: string; + lastSeenAt: string; + provides: string; + owned: string; + accessToken?: string; + publicAddress?: string; + httpsRequired?: string; + synced?: string; + relay?: string; + dnsRebindingProtection?: string; + natLoopbackSupported?: string; + publicAddressMatches?: string; + presence?: string; + ownerID?: string; + home?: string; + sourceTitle?: string; + }; + Connection: ConnectionResponse[]; +} + interface ServerResponse { $: { id: string; @@ -87,6 +127,62 @@ class PlexTvAPI { }); } + public async getDevices(): Promise { + try { + const devicesResp = await this.axios.get( + '/api/resources?includeHttps=1', + { + transformResponse: [], + responseType: 'text', + } + ); + const parsedXml = await xml2js.parseStringPromise( + devicesResp.data as DeviceResponse + ); + return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({ + name: pxml.$.name, + product: pxml.$.product, + productVersion: pxml.$.productVersion, + platform: pxml.$?.platform, + platformVersion: pxml.$?.platformVersion, + device: pxml.$?.device, + clientIdentifier: pxml.$.clientIdentifier, + createdAt: new Date(parseInt(pxml.$?.createdAt, 10) * 1000), + lastSeenAt: new Date(parseInt(pxml.$?.lastSeenAt, 10) * 1000), + provides: pxml.$.provides.split(','), + owned: pxml.$.owned == '1' ? true : false, + accessToken: pxml.$?.accessToken, + publicAddress: pxml.$?.publicAddress, + publicAddressMatches: + pxml.$?.publicAddressMatches == '1' ? true : false, + httpsRequired: pxml.$?.httpsRequired == '1' ? true : false, + synced: pxml.$?.synced == '1' ? true : false, + relay: pxml.$?.relay == '1' ? true : false, + dnsRebindingProtection: + pxml.$?.dnsRebindingProtection == '1' ? true : false, + natLoopbackSupported: + pxml.$?.natLoopbackSupported == '1' ? true : false, + presence: pxml.$?.presence == '1' ? true : false, + ownerID: pxml.$?.ownerID, + home: pxml.$?.home == '1' ? true : false, + sourceTitle: pxml.$?.sourceTitle, + connection: pxml?.Connection?.map((conn: ConnectionResponse) => ({ + protocol: conn.$.protocol, + address: conn.$.address, + port: parseInt(conn.$.port, 10), + uri: conn.$.uri, + local: conn.$.local == '1' ? true : false, + })), + })); + } catch (e) { + logger.error('Something went wrong getting the devices from plex.tv', { + label: 'Plex.tv API', + errorMessage: e.message, + }); + throw new Error('Invalid auth token'); + } + } + public async getUser(): Promise { try { const account = await this.axios.get( @@ -96,7 +192,7 @@ class PlexTvAPI { return account.data.user; } catch (e) { logger.error( - `Something went wrong getting the account from plex.tv: ${e.message}`, + `Something went wrong while getting the account from plex.tv: ${e.message}`, { label: 'Plex.tv API' } ); throw new Error('Invalid auth token'); diff --git a/server/api/radarr.ts b/server/api/radarr.ts index 53d8c7ed..ec0c7956 100644 --- a/server/api/radarr.ts +++ b/server/api/radarr.ts @@ -1,4 +1,5 @@ import Axios, { AxiosInstance } from 'axios'; +import { RadarrSettings } from '../lib/settings'; import logger from '../logger'; interface RadarrMovieOptions { @@ -13,12 +14,13 @@ interface RadarrMovieOptions { searchNow?: boolean; } -interface RadarrMovie { +export interface RadarrMovie { id: number; title: string; isAvailable: boolean; monitored: boolean; tmdbId: number; + imdbId: string; titleSlug: string; folderName: string; path: string; @@ -45,7 +47,39 @@ export interface RadarrProfile { name: string; } +interface QueueItem { + movieId: number; + size: number; + title: string; + sizeleft: number; + timeleft: string; + estimatedCompletionTime: string; + status: string; + trackedDownloadStatus: string; + trackedDownloadState: string; + downloadId: string; + protocol: string; + downloadClient: string; + indexer: string; + id: number; +} + +interface QueueResponse { + page: number; + pageSize: number; + sortKey: string; + sortDirection: string; + totalRecords: number; + records: QueueItem[]; +} + class RadarrAPI { + static buildRadarrUrl(radarrSettings: RadarrSettings, path?: string): string { + return `${radarrSettings.useSsl ? 'https' : 'http'}://${ + radarrSettings.hostname + }:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}${path}`; + } + private axios: AxiosInstance; constructor({ url, apiKey }: { url: string; apiKey: string }) { this.axios = Axios.create({ @@ -76,8 +110,89 @@ class RadarrAPI { } }; - public addMovie = async (options: RadarrMovieOptions): Promise => { + public async getMovieByTmdbId(id: number): Promise { try { + const response = await this.axios.get('/movie/lookup', { + params: { + term: `tmdb:${id}`, + }, + }); + + if (!response.data[0]) { + throw new Error('Movie not found'); + } + + return response.data[0]; + } catch (e) { + logger.error('Error retrieving movie by TMDb ID', { + label: 'Radarr API', + message: e.message, + }); + throw new Error('Movie not found'); + } + } + + public addMovie = async ( + options: RadarrMovieOptions + ): Promise => { + try { + const movie = await this.getMovieByTmdbId(options.tmdbId); + + if (movie.downloaded) { + logger.info( + 'Title already exists and is available. Skipping add and returning success', + { + label: 'Radarr', + } + ); + return movie; + } + + // movie exists in radarr but is neither downloaded nor monitored + if (movie.id && !movie.monitored) { + const response = await this.axios.put(`/movie`, { + ...movie, + title: options.title, + qualityProfileId: options.qualityProfileId, + profileId: options.profileId, + titleSlug: options.tmdbId.toString(), + minimumAvailability: options.minimumAvailability, + tmdbId: options.tmdbId, + year: options.year, + rootFolderPath: options.rootFolderPath, + monitored: options.monitored, + addOptions: { + searchForMovie: options.searchNow, + }, + }); + + if (response.data.monitored) { + logger.info( + 'Found existing title in Radarr and set it to monitored. Returning success', + { label: 'Radarr' } + ); + logger.debug('Radarr update details', { + label: 'Radarr', + movie: response.data, + }); + return response.data; + } else { + logger.error('Failed to update existing movie in Radarr', { + label: 'Radarr', + options, + }); + throw new Error('Failed to update existing movie in Radarr'); + } + } + + if (movie.id) { + logger.info( + 'Movie is already monitored in Radarr. Skipping add and returning success', + { label: 'Radarr' } + ); + return movie; + } + const response = await this.axios.post(`/movie`, { title: options.title, qualityProfileId: options.qualityProfileId, @@ -104,9 +219,9 @@ class RadarrAPI { label: 'Radarr', options, }); - return false; + throw new Error('Failed to add movie to Radarr'); } - return true; + return response.data; } catch (e) { logger.error( 'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.', @@ -117,10 +232,7 @@ class RadarrAPI { response: e?.response?.data, } ); - if (e?.response?.data?.[0]?.errorCode === 'MovieExistsValidator') { - return true; - } - return false; + throw new Error('Failed to add movie to Radarr'); } }; @@ -143,6 +255,16 @@ class RadarrAPI { throw new Error(`[Radarr] Failed to retrieve root folders: ${e.message}`); } }; + + public getQueue = async (): Promise => { + try { + const response = await this.axios.get(`/queue`); + + return response.data.records; + } catch (e) { + throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`); + } + }; } export default RadarrAPI; diff --git a/server/api/sonarr.ts b/server/api/sonarr.ts index e3cd2353..29c6b431 100644 --- a/server/api/sonarr.ts +++ b/server/api/sonarr.ts @@ -1,9 +1,18 @@ import Axios, { AxiosInstance } from 'axios'; +import { SonarrSettings } from '../lib/settings'; import logger from '../logger'; interface SonarrSeason { seasonNumber: number; monitored: boolean; + statistics?: { + previousAiring?: string; + episodeFileCount: number; + episodeCount: number; + totalEpisodeCount: number; + sizeOnDisk: number; + percentOfEpisodes: number; + }; } export interface SonarrSeries { @@ -55,6 +64,33 @@ export interface SonarrSeries { }; } +interface QueueItem { + seriesId: number; + episodeId: number; + size: number; + title: string; + sizeleft: number; + timeleft: string; + estimatedCompletionTime: string; + status: string; + trackedDownloadStatus: string; + trackedDownloadState: string; + downloadId: string; + protocol: string; + downloadClient: string; + indexer: string; + id: number; +} + +interface QueueResponse { + page: number; + pageSize: number; + sortKey: string; + sortDirection: string; + totalRecords: number; + records: QueueItem[]; +} + interface SonarrProfile { id: number; name: string; @@ -84,6 +120,12 @@ interface AddSeriesOptions { } class SonarrAPI { + static buildSonarrUrl(sonarrSettings: SonarrSettings, path?: string): string { + return `${sonarrSettings.useSsl ? 'https' : 'http'}://${ + sonarrSettings.hostname + }:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}${path}`; + } + private axios: AxiosInstance; constructor({ url, apiKey }: { url: string; apiKey: string }) { this.axios = Axios.create({ @@ -94,6 +136,38 @@ class SonarrAPI { }); } + public async getSeries(): Promise { + try { + const response = await this.axios.get('/series'); + + return response.data; + } catch (e) { + throw new Error(`[Radarr] Failed to retrieve series: ${e.message}`); + } + } + + public async getSeriesByTitle(title: string): Promise { + try { + const response = await this.axios.get('/series/lookup', { + params: { + term: title, + }, + }); + + if (!response.data[0]) { + throw new Error('No series found'); + } + + return response.data; + } catch (e) { + logger.error('Error retrieving series by series title', { + label: 'Sonarr API', + message: e.message, + }); + throw new Error('No series found'); + } + } + public async getSeriesByTvdbId(id: number): Promise { try { const response = await this.axios.get('/series/lookup', { @@ -116,7 +190,7 @@ class SonarrAPI { } } - public async addSeries(options: AddSeriesOptions): Promise { + public async addSeries(options: AddSeriesOptions): Promise { try { const series = await this.getSeriesByTvdbId(options.tvdbid); @@ -138,19 +212,19 @@ class SonarrAPI { logger.info('Sonarr accepted request. Updated existing series', { label: 'Sonarr', }); - logger.debug('Sonarr add details', { + logger.debug('Sonarr update details', { label: 'Sonarr', movie: newSeriesResponse.data, }); } else { - logger.error('Failed to add movie to Sonarr', { + logger.error('Failed to update series in Sonarr', { label: 'Sonarr', options, }); - return false; + throw new Error('Failed to update series in Sonarr'); } - return true; + return newSeriesResponse.data; } const createdSeriesResponse = await this.axios.post( @@ -189,18 +263,18 @@ class SonarrAPI { label: 'Sonarr', options, }); - return false; + throw new Error('Failed to add series to Sonarr'); } - return true; + return createdSeriesResponse.data; } catch (e) { - logger.error('Something went wrong adding a series to Sonarr', { + logger.error('Something went wrong while adding a series to Sonarr.', { label: 'Sonarr API', errorMessage: e.message, error: e, response: e?.response?.data, }); - return false; + throw new Error('Failed to add series'); } } @@ -210,7 +284,7 @@ class SonarrAPI { return response.data; } catch (e) { - logger.error('Something went wrong retrieving Sonarr profiles', { + logger.error('Something went wrong while retrieving Sonarr profiles.', { label: 'Sonarr API', message: e.message, }); @@ -224,10 +298,14 @@ class SonarrAPI { return response.data; } catch (e) { - logger.error('Something went wrong retrieving Sonarr root folders', { - label: 'Sonarr API', - message: e.message, - }); + logger.error( + 'Something went wrong while retrieving Sonarr root folders.', + { + label: 'Sonarr API', + message: e.message, + } + ); + throw new Error('Failed to get root folders'); } } @@ -256,6 +334,16 @@ class SonarrAPI { return newSeasons; } + + public getQueue = async (): Promise => { + try { + const response = await this.axios.get(`/queue`); + + return response.data.records; + } catch (e) { + throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`); + } + }; } export default SonarrAPI; diff --git a/server/api/themoviedb.ts b/server/api/themoviedb.ts index 83fa3fad..fddab1ba 100644 --- a/server/api/themoviedb.ts +++ b/server/api/themoviedb.ts @@ -898,11 +898,11 @@ class TheMovieDb { } throw new Error( - `[TMDB] Failed to find a tv show with the provided TVDB id: ${tvdbId}` + `[TMDB] Failed to find a TV show with the provided TVDB ID: ${tvdbId}` ); } catch (e) { throw new Error( - `[TMDB] Failed to get tv show by external tvdb ID: ${e.message}` + `[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}` ); } } diff --git a/server/entity/Media.ts b/server/entity/Media.ts index dc269f5a..9ca195c6 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -8,11 +8,16 @@ import { UpdateDateColumn, getRepository, In, + AfterLoad, } from 'typeorm'; import { MediaRequest } from './MediaRequest'; import { MediaStatus, MediaType } from '../constants/media'; import logger from '../logger'; import Season from './Season'; +import { getSettings } from '../lib/settings'; +import RadarrAPI from '../api/radarr'; +import downloadTracker, { DownloadingItem } from '../lib/downloadtracker'; +import SonarrAPI from '../api/sonarr'; @Entity() class Media { @@ -104,9 +109,170 @@ class Media { @Column({ type: 'datetime', nullable: true }) public mediaAddedAt: Date; + @Column({ nullable: true }) + public serviceId?: number; + + @Column({ nullable: true }) + public serviceId4k?: number; + + @Column({ nullable: true }) + public externalServiceId?: number; + + @Column({ nullable: true }) + public externalServiceId4k?: number; + + @Column({ nullable: true }) + public externalServiceSlug?: string; + + @Column({ nullable: true }) + public externalServiceSlug4k?: string; + + @Column({ nullable: true }) + public ratingKey?: string; + + @Column({ nullable: true }) + public ratingKey4k?: string; + + public serviceUrl?: string; + public serviceUrl4k?: string; + public downloadStatus?: DownloadingItem[] = []; + public downloadStatus4k?: DownloadingItem[] = []; + + public plexUrl?: string; + public plexUrl4k?: string; + constructor(init?: Partial) { Object.assign(this, init); } + + @AfterLoad() + public setPlexUrls(): void { + const machineId = getSettings().plex.machineId; + if (this.ratingKey) { + this.plexUrl = `https://app.plex.tv/desktop#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${this.ratingKey}`; + } + if (this.ratingKey4k) { + this.plexUrl4k = `https://app.plex.tv/desktop#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${this.ratingKey4k}`; + } + } + + @AfterLoad() + public setServiceUrl(): void { + if (this.mediaType === MediaType.MOVIE) { + if (this.serviceId !== null && this.externalServiceSlug !== null) { + const settings = getSettings(); + const server = settings.radarr.find( + (radarr) => radarr.id === this.serviceId + ); + + if (server) { + this.serviceUrl = server.externalUrl + ? `${server.externalUrl}/movie/${this.externalServiceSlug}` + : RadarrAPI.buildRadarrUrl( + server, + `/movie/${this.externalServiceSlug}` + ); + } + } + + if (this.serviceId4k !== null && this.externalServiceSlug4k !== null) { + const settings = getSettings(); + const server = settings.radarr.find( + (radarr) => radarr.id === this.serviceId4k + ); + + if (server) { + this.serviceUrl4k = server.externalUrl + ? `${server.externalUrl}/movie/${this.externalServiceSlug4k}` + : RadarrAPI.buildRadarrUrl( + server, + `/movie/${this.externalServiceSlug4k}` + ); + } + } + } + + if (this.mediaType === MediaType.TV) { + if (this.serviceId !== null && this.externalServiceSlug !== null) { + const settings = getSettings(); + const server = settings.sonarr.find( + (sonarr) => sonarr.id === this.serviceId + ); + + if (server) { + this.serviceUrl = server.externalUrl + ? `${server.externalUrl}/series/${this.externalServiceSlug}` + : SonarrAPI.buildSonarrUrl( + server, + `/series/${this.externalServiceSlug}` + ); + } + } + + if (this.serviceId4k !== null && this.externalServiceSlug4k !== null) { + const settings = getSettings(); + const server = settings.sonarr.find( + (sonarr) => sonarr.id === this.serviceId4k + ); + + if (server) { + this.serviceUrl4k = server.externalUrl + ? `${server.externalUrl}/series/${this.externalServiceSlug4k}` + : SonarrAPI.buildSonarrUrl( + server, + `/series/${this.externalServiceSlug4k}` + ); + } + } + } + } + + @AfterLoad() + public getDownloadingItem(): void { + if (this.mediaType === MediaType.MOVIE) { + if ( + this.externalServiceId !== undefined && + this.serviceId !== undefined + ) { + this.downloadStatus = downloadTracker.getMovieProgress( + this.serviceId, + this.externalServiceId + ); + } + + if ( + this.externalServiceId4k !== undefined && + this.serviceId4k !== undefined + ) { + this.downloadStatus4k = downloadTracker.getMovieProgress( + this.serviceId4k, + this.externalServiceId4k + ); + } + } + + if (this.mediaType === MediaType.TV) { + if ( + this.externalServiceId !== undefined && + this.serviceId !== undefined + ) { + this.downloadStatus = downloadTracker.getSeriesProgress( + this.serviceId, + this.externalServiceId + ); + } + + if ( + this.externalServiceId4k !== undefined && + this.serviceId4k !== undefined + ) { + this.downloadStatus4k = downloadTracker.getSeriesProgress( + this.serviceId4k, + this.externalServiceId4k + ); + } + } + } } export default Media; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 3b64f472..1ba74961 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -201,6 +201,18 @@ export class MediaRequest { } } + @AfterInsert() + public async autoapprovalNotification(): Promise { + const settings = getSettings().notifications; + + if ( + settings.autoapprovalEnabled && + this.status === MediaRequestStatus.APPROVED + ) { + this.notifyApprovedOrDeclined(); + } + } + @AfterUpdate() @AfterInsert() public async updateParentStatus(): Promise { @@ -399,37 +411,46 @@ export class MediaRequest { tmdbId: movie.id, year: Number(movie.release_date.slice(0, 4)), monitored: true, - searchNow: true, + searchNow: !radarrSettings.preventSearch, }) - .then(async (success) => { - if (!success) { - media.status = MediaStatus.UNKNOWN; - await mediaRepository.save(media); - logger.warn( - 'Newly added movie request failed to add to Radarr, marking as unknown', - { - label: 'Media Request', - } - ); - const userRepository = getRepository(User); - const admin = await userRepository.findOneOrFail({ - select: ['id', 'plexToken'], - order: { id: 'ASC' }, - }); - notificationManager.sendNotification(Notification.MEDIA_FAILED, { - subject: movie.title, - message: 'Movie failed to add to Radarr', - notifyUser: admin, - media, - image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, - }); - } + .then(async (radarrMovie) => { + media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] = + radarrMovie.id; + media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = + radarrMovie.titleSlug; + media[this.is4k ? 'serviceId4k' : 'serviceId'] = radarrSettings?.id; + await mediaRepository.save(media); + }) + .catch(async () => { + media.status = MediaStatus.UNKNOWN; + await mediaRepository.save(media); + logger.warn( + 'Newly added movie request failed to add to Radarr, marking as unknown', + { + label: 'Media Request', + } + ); + const userRepository = getRepository(User); + const admin = await userRepository.findOneOrFail({ + select: ['id', 'plexToken'], + order: { id: 'ASC' }, + }); + notificationManager.sendNotification(Notification.MEDIA_FAILED, { + subject: movie.title, + message: 'Movie failed to add to Radarr', + notifyUser: admin, + media, + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, + }); }); logger.info('Sent request to Radarr', { label: 'Media Request' }); } catch (e) { - throw new Error( - `[MediaRequest] Request failed to send to radarr: ${e.message}` - ); + const errorMessage = `Request failed to send to radarr: ${e.message}`; + logger.error('Request failed to send to Radarr', { + label: 'Media Request', + errorMessage, + }); + throw new Error(errorMessage); } } } @@ -501,8 +522,10 @@ export class MediaRequest { }:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`, }); const series = await tmdb.getTvShow({ tvId: media.tmdbId }); + const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId; - if (!series.external_ids.tvdb_id) { + if (!tvdbId) { + this.handleRemoveParentUpdate(); throw new Error('Series was missing tvdb id'); } @@ -539,7 +562,7 @@ export class MediaRequest { if (this.profileId && this.profileId !== qualityProfile) { qualityProfile = this.profileId; - logger.info(`Request has an override profile id: ${qualityProfile}`, { + logger.info(`Request has an override profile ID: ${qualityProfile}`, { label: 'Media Request', }); } @@ -550,49 +573,68 @@ export class MediaRequest { profileId: qualityProfile, rootFolderPath: rootFolder, title: series.name, - tvdbid: series.external_ids.tvdb_id, + tvdbid: tvdbId, seasons: this.seasons.map((season) => season.seasonNumber), seasonFolder: sonarrSettings.enableSeasonFolders, seriesType, monitored: true, - searchNow: true, + searchNow: !sonarrSettings.preventSearch, }) - .then(async (success) => { - if (!success) { - media.status = MediaStatus.UNKNOWN; - await mediaRepository.save(media); - logger.warn( - 'Newly added series request failed to add to Sonarr, marking as unknown', - { - label: 'Media Request', - } - ); - const userRepository = getRepository(User); - const admin = await userRepository.findOneOrFail({ - order: { id: 'ASC' }, - }); - notificationManager.sendNotification(Notification.MEDIA_FAILED, { - subject: series.name, - message: 'Series failed to add to Sonarr', - notifyUser: admin, - image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${series.poster_path}`, - media, - extra: [ - { - name: 'Seasons', - value: this.seasons - .map((season) => season.seasonNumber) - .join(', '), - }, - ], - }); + .then(async (sonarrSeries) => { + // We grab media again here to make sure we have the latest version of it + const media = await mediaRepository.findOne({ + where: { id: this.media.id }, + relations: ['requests'], + }); + + if (!media) { + throw new Error('Media data is missing'); } + + media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] = + sonarrSeries.id; + media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = + sonarrSeries.titleSlug; + media[this.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id; + await mediaRepository.save(media); + }) + .catch(async () => { + media.status = MediaStatus.UNKNOWN; + await mediaRepository.save(media); + logger.warn( + 'Newly added series request failed to add to Sonarr, marking as unknown', + { + label: 'Media Request', + } + ); + const userRepository = getRepository(User); + const admin = await userRepository.findOneOrFail({ + order: { id: 'ASC' }, + }); + notificationManager.sendNotification(Notification.MEDIA_FAILED, { + subject: series.name, + message: 'Series failed to add to Sonarr', + notifyUser: admin, + image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${series.poster_path}`, + media, + extra: [ + { + name: 'Seasons', + value: this.seasons + .map((season) => season.seasonNumber) + .join(', '), + }, + ], + }); }); logger.info('Sent request to Sonarr', { label: 'Media Request' }); } catch (e) { - throw new Error( - `[MediaRequest] Request failed to send to sonarr: ${e.message}` - ); + const errorMessage = `Request failed to send to sonarr: ${e.message}`; + logger.error('Request failed to send to Sonarr', { + label: 'Media Request', + errorMessage, + }); + throw new Error(errorMessage); } } } diff --git a/server/entity/User.ts b/server/entity/User.ts index 0b05efb2..078fe15e 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -6,6 +6,7 @@ import { UpdateDateColumn, OneToMany, RelationCount, + AfterLoad, } from 'typeorm'; import { Permission, hasPermission } from '../lib/permissions'; import { MediaRequest } from './MediaRequest'; @@ -25,14 +26,19 @@ export class User { static readonly filteredFields: string[] = ['plexToken', 'password']; + public displayName: string; + @PrimaryGeneratedColumn() public id: number; @Column({ unique: true }) public email: string; - @Column() - public username: string; + @Column({ nullable: true }) + public plexUsername: string; + + @Column({ nullable: true }) + public username?: string; @Column({ nullable: true, select: false }) public password?: string; @@ -125,4 +131,9 @@ export class User { }); } } + + @AfterLoad() + public setDisplayName(): void { + this.displayName = this.username || this.plexUsername; + } } diff --git a/server/index.ts b/server/index.ts index 6733af87..56481682 100644 --- a/server/index.ts +++ b/server/index.ts @@ -5,6 +5,7 @@ import { createConnection, getRepository } from 'typeorm'; import routes from './routes'; import bodyParser from 'body-parser'; import cookieParser from 'cookie-parser'; +import csurf from 'csurf'; import session, { Store } from 'express-session'; import { TypeormStore } from 'connect-typeorm/out'; import YAML from 'yamljs'; @@ -22,6 +23,7 @@ import { getAppVersion } from './utils/appVersion'; import SlackAgent from './lib/notifications/agents/slack'; import PushoverAgent from './lib/notifications/agents/pushover'; import WebhookAgent from './lib/notifications/agents/webhook'; +import { getClientIp } from '@supercharge/request-ip'; const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); @@ -59,11 +61,47 @@ app startJobs(); const server = express(); + if (settings.main.trustProxy) { + server.enable('trust proxy'); + } server.use(cookieParser()); server.use(bodyParser.json()); server.use(bodyParser.urlencoded({ extended: true })); + server.use((req, res, next) => { + try { + const descriptor = Object.getOwnPropertyDescriptor(req, 'ip'); + if (descriptor?.writable === true) { + req.ip = getClientIp(req) ?? ''; + } + } catch (e) { + logger.error('Failed to attach the ip to the request', { + label: 'Middleware', + message: e.message, + }); + } finally { + next(); + } + }); + if (settings.main.csrfProtection) { + server.use( + csurf({ + cookie: { + httpOnly: true, + sameSite: true, + secure: !dev, + }, + }) + ); + server.use((req, res, next) => { + res.cookie('XSRF-TOKEN', req.csrfToken(), { + sameSite: true, + secure: !dev, + }); + next(); + }); + } - // Setup sessions + // Set up sessions const sessionRespository = getRepository(Session); server.use( '/api', diff --git a/server/interfaces/api/plexInterfaces.ts b/server/interfaces/api/plexInterfaces.ts new file mode 100644 index 00000000..42ec9cb4 --- /dev/null +++ b/server/interfaces/api/plexInterfaces.ts @@ -0,0 +1,45 @@ +import { PlexSettings } from '../../lib/settings'; + +export interface PlexStatus { + settings: PlexSettings; + status: number; + message: string; +} + +export interface PlexConnection { + protocol: string; + address: string; + port: number; + uri: string; + local: boolean; + status?: number; + message?: string; + host?: string; +} + +export interface PlexDevice { + name: string; + product: string; + productVersion: string; + platform: string; + platformVersion: string; + device: string; + clientIdentifier: string; + createdAt: Date; + lastSeenAt: Date; + provides: string[]; + owned: boolean; + accessToken?: string; + publicAddress?: string; + httpsRequired?: boolean; + synced?: boolean; + relay?: boolean; + dnsRebindingProtection?: boolean; + natLoopbackSupported?: boolean; + publicAddressMatches?: boolean; + presence?: boolean; + ownerID?: string; + home?: boolean; + sourceTitle?: string; + connection: PlexConnection[]; +} diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index eba40ee2..52be4c4c 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -9,4 +9,5 @@ export interface PublicSettingsResponse { initialized: boolean; movie4kEnabled: boolean; series4kEnabled: boolean; + hideAvailable: boolean; } diff --git a/server/job/plexsync/index.ts b/server/job/plexsync/index.ts index 2c3330ca..cab4f5ef 100644 --- a/server/job/plexsync/index.ts +++ b/server/job/plexsync/index.ts @@ -68,6 +68,7 @@ class JobPlexSync { private async processMovie(plexitem: PlexLibraryItem) { const mediaRepository = getRepository(Media); + try { if (plexitem.guid.match(plexRegex)) { const metadata = await this.plexClient.getMetadata(plexitem.ratingKey); @@ -89,6 +90,15 @@ class JobPlexSync { newMedia.tmdbId = Number(tmdbMatch); } }); + if (newMedia.imdbId && !newMedia.tmdbId) { + const tmdbMovie = await this.tmdb.getMovieByImdbId({ + imdbId: newMedia.imdbId, + }); + newMedia.tmdbId = tmdbMovie.id; + } + if (!newMedia.tmdbId) { + throw new Error('Unable to find TMDb ID'); + } const has4k = metadata.Media.some( (media) => media.videoResolution === '4k' @@ -129,6 +139,23 @@ class JobPlexSync { changedExisting = true; } + if ( + (hasOtherResolution || (has4k && !this.enable4kMovie)) && + existing.ratingKey !== plexitem.ratingKey + ) { + existing.ratingKey = plexitem.ratingKey; + changedExisting = true; + } + + if ( + has4k && + this.enable4kMovie && + existing.ratingKey4k !== plexitem.ratingKey + ) { + existing.ratingKey4k = plexitem.ratingKey; + changedExisting = true; + } + if (changedExisting) { await mediaRepository.save(existing); this.log( @@ -151,6 +178,12 @@ class JobPlexSync { : MediaStatus.UNKNOWN; newMedia.mediaType = MediaType.MOVIE; newMedia.mediaAddedAt = new Date(plexitem.addedAt * 1000); + newMedia.ratingKey = + hasOtherResolution || (!this.enable4kMovie && has4k) + ? plexitem.ratingKey + : undefined; + newMedia.ratingKey4k = + has4k && this.enable4kMovie ? plexitem.ratingKey : undefined; await mediaRepository.save(newMedia); this.log(`Saved ${plexitem.title}`); } @@ -172,14 +205,14 @@ class JobPlexSync { } if (!tmdbMovieId) { - throw new Error('Unable to find TMDB ID'); + throw new Error('Unable to find TMDb ID'); } await this.processMovieWithId(plexitem, tmdbMovie, tmdbMovieId); } } catch (e) { this.log( - `Failed to process plex item. ratingKey: ${plexitem.ratingKey}`, + `Failed to process Plex item. ratingKey: ${plexitem.ratingKey}`, 'error', { errorMessage: e.message, @@ -233,6 +266,23 @@ class JobPlexSync { changedExisting = true; } + if ( + (hasOtherResolution || (has4k && !this.enable4kMovie)) && + existing.ratingKey !== plexitem.ratingKey + ) { + existing.ratingKey = plexitem.ratingKey; + changedExisting = true; + } + + if ( + has4k && + this.enable4kMovie && + existing.ratingKey4k !== plexitem.ratingKey + ) { + existing.ratingKey4k = plexitem.ratingKey; + changedExisting = true; + } + if (changedExisting) { await mediaRepository.save(existing); this.log( @@ -263,6 +313,12 @@ class JobPlexSync { ? MediaStatus.AVAILABLE : MediaStatus.UNKNOWN; newMedia.mediaType = MediaType.MOVIE; + newMedia.ratingKey = + hasOtherResolution || (!this.enable4kMovie && has4k) + ? plexitem.ratingKey + : undefined; + newMedia.ratingKey4k = + has4k && this.enable4kMovie ? plexitem.ratingKey : undefined; await mediaRepository.save(newMedia); this.log(`Saved ${tmdbMovie.title}`); } @@ -302,12 +358,14 @@ class JobPlexSync { let tvShow: TmdbTvDetails | null = null; try { - const metadata = await this.plexClient.getMetadata( + const ratingKey = plexitem.grandparentRatingKey ?? - plexitem.parentRatingKey ?? - plexitem.ratingKey, - { includeChildren: true } - ); + plexitem.parentRatingKey ?? + plexitem.ratingKey; + const metadata = await this.plexClient.getMetadata(ratingKey, { + includeChildren: true, + }); + if (metadata.guid.match(tvdbRegex)) { const matchedtvdb = metadata.guid.match(tvdbRegex); @@ -333,7 +391,7 @@ class JobPlexSync { await this.processHamaSpecials(metadata, Number(tvdbId)); } else { this.log( - `Hama id ${plexitem.guid} detected, but library agent is not set to Hama`, + `Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`, 'warn' ); } @@ -343,7 +401,7 @@ class JobPlexSync { if (!animeList.isLoaded()) { this.log( - `Hama id ${plexitem.guid} detected, but library agent is not set to Hama`, + `Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`, 'warn' ); } else if (matched?.[1]) { @@ -399,7 +457,7 @@ class JobPlexSync { return; } - // Lets get the available seasons from plex + // Lets get the available seasons from Plex const seasons = tvShow.seasons; const media = await this.getExisting(tvShow.id, MediaType.TV); @@ -427,13 +485,17 @@ class JobPlexSync { // Check if we found the matching season and it has all the available episodes if (matchedPlexSeason) { - // If we have a matched plex season, get its children metadata so we can check details + // If we have a matched Plex season, get its children metadata so we can check details const episodes = await this.plexClient.getChildrenMetadata( matchedPlexSeason.ratingKey ); // Total episodes that are in standard definition (not 4k) const totalStandard = episodes.filter((episode) => - episode.Media.some((media) => media.videoResolution !== '4k') + !this.enable4kShow + ? true + : episode.Media.some( + (media) => media.videoResolution !== '4k' + ) ).length; // Total episodes that are in 4k @@ -441,6 +503,23 @@ class JobPlexSync { episode.Media.some((media) => media.videoResolution === '4k') ).length; + if ( + media && + (totalStandard > 0 || (total4k > 0 && !this.enable4kShow)) && + media.ratingKey !== ratingKey + ) { + media.ratingKey = ratingKey; + } + + if ( + media && + total4k > 0 && + this.enable4kShow && + media.ratingKey4k !== ratingKey + ) { + media.ratingKey4k = ratingKey; + } + if (existingSeason) { // These ternary statements look super confusing, but they are simply // setting the status to AVAILABLE if all of a type is there, partially if some, @@ -452,9 +531,9 @@ class JobPlexSync { ? MediaStatus.PARTIALLY_AVAILABLE : existingSeason.status; existingSeason.status4k = - total4k === season.episode_count + this.enable4kShow && total4k === season.episode_count ? MediaStatus.AVAILABLE - : total4k > 0 + : this.enable4kShow && total4k > 0 ? MediaStatus.PARTIALLY_AVAILABLE : existingSeason.status4k; } else { @@ -470,9 +549,9 @@ class JobPlexSync { ? MediaStatus.PARTIALLY_AVAILABLE : MediaStatus.UNKNOWN, status4k: - total4k === season.episode_count + this.enable4kShow && total4k === season.episode_count ? MediaStatus.AVAILABLE - : total4k > 0 + : this.enable4kShow && total4k > 0 ? MediaStatus.PARTIALLY_AVAILABLE : MediaStatus.UNKNOWN, }) @@ -547,20 +626,36 @@ class JobPlexSync { media.mediaAddedAt = new Date(plexitem.addedAt * 1000); } - media.status = isAllStandardSeasons - ? MediaStatus.AVAILABLE - : media.seasons.some( - (season) => season.status !== MediaStatus.UNKNOWN - ) - ? MediaStatus.PARTIALLY_AVAILABLE - : MediaStatus.UNKNOWN; - media.status4k = isAll4kSeasons - ? MediaStatus.AVAILABLE - : media.seasons.some( - (season) => season.status4k !== MediaStatus.UNKNOWN - ) - ? MediaStatus.PARTIALLY_AVAILABLE - : MediaStatus.UNKNOWN; + // If the show is already available, and there are no new seasons, dont adjust + // the status + const shouldStayAvailable = + media.status === MediaStatus.AVAILABLE && + newSeasons.filter( + (season) => season.status !== MediaStatus.UNKNOWN + ).length === 0; + const shouldStayAvailable4k = + media.status4k === MediaStatus.AVAILABLE && + newSeasons.filter( + (season) => season.status4k !== MediaStatus.UNKNOWN + ).length === 0; + + media.status = + isAllStandardSeasons || shouldStayAvailable + ? MediaStatus.AVAILABLE + : media.seasons.some( + (season) => season.status !== MediaStatus.UNKNOWN + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN; + media.status4k = + (isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow + ? MediaStatus.AVAILABLE + : this.enable4kShow && + media.seasons.some( + (season) => season.status4k !== MediaStatus.UNKNOWN + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN; await mediaRepository.save(media); this.log(`Updating existing title: ${tvShow.name}`); } else { @@ -577,13 +672,15 @@ class JobPlexSync { ) ? MediaStatus.PARTIALLY_AVAILABLE : MediaStatus.UNKNOWN, - status4k: isAll4kSeasons - ? MediaStatus.AVAILABLE - : newSeasons.some( - (season) => season.status4k !== MediaStatus.UNKNOWN - ) - ? MediaStatus.PARTIALLY_AVAILABLE - : MediaStatus.UNKNOWN, + status4k: + isAll4kSeasons && this.enable4kShow + ? MediaStatus.AVAILABLE + : this.enable4kShow && + newSeasons.some( + (season) => season.status4k !== MediaStatus.UNKNOWN + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN, }); await mediaRepository.save(newMedia); this.log(`Saved ${tvShow.name}`); @@ -594,7 +691,7 @@ class JobPlexSync { } } catch (e) { this.log( - `Failed to process plex item. ratingKey: ${ + `Failed to process Plex item. ratingKey: ${ plexitem.grandparentRatingKey ?? plexitem.parentRatingKey ?? plexitem.ratingKey @@ -763,7 +860,8 @@ class JobPlexSync { this.log( this.isRecentOnly ? 'Recently Added Scan Complete' - : 'Full Scan Complete' + : 'Full Scan Complete', + 'info' ); } catch (e) { logger.error('Sync interrupted', { diff --git a/server/job/radarrsync/index.ts b/server/job/radarrsync/index.ts new file mode 100644 index 00000000..57f88ee0 --- /dev/null +++ b/server/job/radarrsync/index.ts @@ -0,0 +1,248 @@ +import { uniqWith } from 'lodash'; +import { getRepository } from 'typeorm'; +import { v4 as uuid } from 'uuid'; +import RadarrAPI, { RadarrMovie } from '../../api/radarr'; +import { MediaStatus, MediaType } from '../../constants/media'; +import Media from '../../entity/Media'; +import { getSettings, RadarrSettings } from '../../lib/settings'; +import logger from '../../logger'; + +const BUNDLE_SIZE = 50; +const UPDATE_RATE = 4 * 1000; + +interface SyncStatus { + running: boolean; + progress: number; + total: number; + currentServer: RadarrSettings; + servers: RadarrSettings[]; +} + +class JobRadarrSync { + private running = false; + private progress = 0; + private enable4k = false; + private sessionId: string; + private servers: RadarrSettings[]; + private currentServer: RadarrSettings; + private radarrApi: RadarrAPI; + private items: RadarrMovie[] = []; + + public async run() { + const settings = getSettings(); + const sessionId = uuid(); + this.sessionId = sessionId; + this.log('Radarr sync starting', 'info', { sessionId }); + + try { + this.running = true; + + // Remove any duplicate Radarr servers and assign them to the servers field + this.servers = uniqWith(settings.radarr, (radarrA, radarrB) => { + return ( + radarrA.hostname === radarrB.hostname && + radarrA.port === radarrB.port && + radarrA.baseUrl === radarrB.baseUrl + ); + }); + + this.enable4k = settings.radarr.some((radarr) => radarr.is4k); + if (this.enable4k) { + this.log( + 'At least one 4K Radarr server was detected. 4K movie detection is now enabled.', + 'info' + ); + } + + for (const server of this.servers) { + this.currentServer = server; + if (server.syncEnabled) { + this.log( + `Beginning to process Radarr server: ${server.name}`, + 'info' + ); + + this.radarrApi = new RadarrAPI({ + apiKey: server.apiKey, + url: RadarrAPI.buildRadarrUrl(server, '/api/v3'), + }); + + this.items = await this.radarrApi.getMovies(); + + await this.loop({ sessionId }); + } else { + this.log(`Sync not enabled. Skipping Radarr server: ${server.name}`); + } + } + + this.log('Radarr sync complete', 'info'); + } catch (e) { + this.log('Something went wrong.', 'error', { errorMessage: e.message }); + } finally { + // If a new scanning session hasnt started, set running back to false + if (this.sessionId === sessionId) { + this.running = false; + } + } + } + + public status(): SyncStatus { + return { + running: this.running, + progress: this.progress, + total: this.items.length, + currentServer: this.currentServer, + servers: this.servers, + }; + } + + public cancel(): void { + this.running = false; + } + + private async processRadarrMovie(radarrMovie: RadarrMovie) { + const mediaRepository = getRepository(Media); + const server4k = this.enable4k && this.currentServer.is4k; + + const media = await mediaRepository.findOne({ + where: { tmdbId: radarrMovie.tmdbId }, + }); + + if (media) { + let isChanged = false; + if (media.status === MediaStatus.AVAILABLE) { + this.log(`Movie already available: ${radarrMovie.title}`); + } else { + media[server4k ? 'status4k' : 'status'] = radarrMovie.downloaded + ? MediaStatus.AVAILABLE + : MediaStatus.PROCESSING; + this.log( + `Updated existing ${server4k ? '4K ' : ''}movie ${ + radarrMovie.title + } to status ${MediaStatus[media[server4k ? 'status4k' : 'status']]}` + ); + isChanged = true; + } + + if ( + media[server4k ? 'serviceId4k' : 'serviceId'] !== this.currentServer.id + ) { + media[server4k ? 'serviceId4k' : 'serviceId'] = this.currentServer.id; + this.log(`Updated service ID for media entity: ${radarrMovie.title}`); + isChanged = true; + } + + if ( + media[server4k ? 'externalServiceId4k' : 'externalServiceId'] !== + radarrMovie.id + ) { + media[server4k ? 'externalServiceId4k' : 'externalServiceId'] = + radarrMovie.id; + this.log( + `Updated external service ID for media entity: ${radarrMovie.title}` + ); + isChanged = true; + } + + if ( + media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !== + radarrMovie.titleSlug + ) { + media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = + radarrMovie.titleSlug; + this.log( + `Updated external service slug for media entity: ${radarrMovie.title}` + ); + isChanged = true; + } + + if (isChanged) { + await mediaRepository.save(media); + } + } else { + const newMedia = new Media({ + tmdbId: radarrMovie.tmdbId, + imdbId: radarrMovie.imdbId, + mediaType: MediaType.MOVIE, + serviceId: !server4k ? this.currentServer.id : undefined, + serviceId4k: server4k ? this.currentServer.id : undefined, + externalServiceId: !server4k ? radarrMovie.id : undefined, + externalServiceId4k: server4k ? radarrMovie.id : undefined, + status: + !server4k && radarrMovie.downloaded + ? MediaStatus.AVAILABLE + : !server4k + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN, + status4k: + server4k && radarrMovie.downloaded + ? MediaStatus.AVAILABLE + : server4k + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN, + }); + + this.log( + `Added media for movie ${radarrMovie.title} and set status to ${ + MediaStatus[newMedia[server4k ? 'status4k' : 'status']] + }` + ); + await mediaRepository.save(newMedia); + } + } + + private async processItems(items: RadarrMovie[]) { + await Promise.all( + items.map(async (radarrMovie) => { + await this.processRadarrMovie(radarrMovie); + }) + ); + } + + private async loop({ + start = 0, + end = BUNDLE_SIZE, + sessionId, + }: { + start?: number; + end?: number; + sessionId?: string; + } = {}) { + const slicedItems = this.items.slice(start, end); + + if (!this.running) { + throw new Error('Sync was aborted.'); + } + + if (this.sessionId !== sessionId) { + throw new Error('New session was started. Old session aborted.'); + } + + if (start < this.items.length) { + this.progress = start; + await this.processItems(slicedItems); + + await new Promise((resolve, reject) => + setTimeout(() => { + this.loop({ + start: start + BUNDLE_SIZE, + end: end + BUNDLE_SIZE, + sessionId, + }) + .then(() => resolve()) + .catch((e) => reject(new Error(e.message))); + }, UPDATE_RATE) + ); + } + } + + private log( + message: string, + level: 'info' | 'error' | 'debug' | 'warn' = 'debug', + optional?: Record + ): void { + logger[level](message, { label: 'Radarr Sync', ...optional }); + } +} + +export const jobRadarrSync = new JobRadarrSync(); diff --git a/server/job/schedule.ts b/server/job/schedule.ts index 82945a46..342f54a1 100644 --- a/server/job/schedule.ts +++ b/server/job/schedule.ts @@ -1,10 +1,17 @@ import schedule from 'node-schedule'; import { jobPlexFullSync, jobPlexRecentSync } from './plexsync'; import logger from '../logger'; +import { jobRadarrSync } from './radarrsync'; +import { jobSonarrSync } from './sonarrsync'; +import downloadTracker from '../lib/downloadtracker'; interface ScheduledJob { + id: string; job: schedule.Job; name: string; + type: 'process' | 'command'; + running?: () => boolean; + cancelFn?: () => void; } export const scheduledJobs: ScheduledJob[] = []; @@ -12,21 +19,80 @@ export const scheduledJobs: ScheduledJob[] = []; export const startJobs = (): void => { // Run recently added plex sync every 5 minutes scheduledJobs.push({ + id: 'plex-recently-added-sync', name: 'Plex Recently Added Sync', + type: 'process', job: schedule.scheduleJob('0 */5 * * * *', () => { logger.info('Starting scheduled job: Plex Recently Added Sync', { label: 'Jobs', }); jobPlexRecentSync.run(); }), + running: () => jobPlexRecentSync.status().running, + cancelFn: () => jobPlexRecentSync.cancel(), }); + // Run full plex sync every 24 hours scheduledJobs.push({ + id: 'plex-full-sync', name: 'Plex Full Library Sync', + type: 'process', job: schedule.scheduleJob('0 0 3 * * *', () => { logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' }); jobPlexFullSync.run(); }), + running: () => jobPlexFullSync.status().running, + cancelFn: () => jobPlexFullSync.cancel(), + }); + + // Run full radarr sync every 24 hours + scheduledJobs.push({ + id: 'radarr-sync', + name: 'Radarr Sync', + type: 'process', + job: schedule.scheduleJob('0 0 4 * * *', () => { + logger.info('Starting scheduled job: Radarr Sync', { label: 'Jobs' }); + jobRadarrSync.run(); + }), + running: () => jobRadarrSync.status().running, + cancelFn: () => jobRadarrSync.cancel(), + }); + + // Run full sonarr sync every 24 hours + scheduledJobs.push({ + id: 'sonarr-sync', + name: 'Sonarr Sync', + type: 'process', + job: schedule.scheduleJob('0 30 4 * * *', () => { + logger.info('Starting scheduled job: Sonarr Sync', { label: 'Jobs' }); + jobSonarrSync.run(); + }), + running: () => jobSonarrSync.status().running, + cancelFn: () => jobSonarrSync.cancel(), + }); + + // Run download sync + scheduledJobs.push({ + id: 'download-sync', + name: 'Download Sync', + type: 'command', + job: schedule.scheduleJob('0 * * * * *', () => { + logger.debug('Starting scheduled job: Download Sync', { label: 'Jobs' }); + downloadTracker.updateDownloads(); + }), + }); + + // Reset download sync + scheduledJobs.push({ + id: 'download-sync-reset', + name: 'Download Sync Reset', + type: 'command', + job: schedule.scheduleJob('0 0 1 * * *', () => { + logger.info('Starting scheduled job: Download Sync Reset', { + label: 'Jobs', + }); + downloadTracker.resetDownloadTracker(); + }), }); logger.info('Scheduled jobs loaded', { label: 'Jobs' }); diff --git a/server/job/sonarrsync/index.ts b/server/job/sonarrsync/index.ts new file mode 100644 index 00000000..6ef45254 --- /dev/null +++ b/server/job/sonarrsync/index.ts @@ -0,0 +1,370 @@ +import { uniqWith } from 'lodash'; +import { getRepository } from 'typeorm'; +import { v4 as uuid } from 'uuid'; +import SonarrAPI, { SonarrSeries } from '../../api/sonarr'; +import TheMovieDb, { TmdbTvDetails } from '../../api/themoviedb'; +import { MediaStatus, MediaType } from '../../constants/media'; +import Media from '../../entity/Media'; +import Season from '../../entity/Season'; +import { getSettings, SonarrSettings } from '../../lib/settings'; +import logger from '../../logger'; + +const BUNDLE_SIZE = 50; +const UPDATE_RATE = 4 * 1000; + +interface SyncStatus { + running: boolean; + progress: number; + total: number; + currentServer: SonarrSettings; + servers: SonarrSettings[]; +} + +class JobSonarrSync { + private running = false; + private progress = 0; + private enable4k = false; + private sessionId: string; + private servers: SonarrSettings[]; + private currentServer: SonarrSettings; + private sonarrApi: SonarrAPI; + private items: SonarrSeries[] = []; + + public async run() { + const settings = getSettings(); + const sessionId = uuid(); + this.sessionId = sessionId; + this.log('Sonarr sync starting', 'info', { sessionId }); + + try { + this.running = true; + + // Remove any duplicate Sonarr servers and assign them to the servers field + this.servers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => { + return ( + sonarrA.hostname === sonarrB.hostname && + sonarrA.port === sonarrB.port && + sonarrA.baseUrl === sonarrB.baseUrl + ); + }); + + this.enable4k = settings.sonarr.some((sonarr) => sonarr.is4k); + if (this.enable4k) { + this.log( + 'At least one 4K Sonarr server was detected. 4K movie detection is now enabled.', + 'info' + ); + } + + for (const server of this.servers) { + this.currentServer = server; + if (server.syncEnabled) { + this.log( + `Beginning to process Sonarr server: ${server.name}`, + 'info' + ); + + this.sonarrApi = new SonarrAPI({ + apiKey: server.apiKey, + url: SonarrAPI.buildSonarrUrl(server, '/api/v3'), + }); + + this.items = await this.sonarrApi.getSeries(); + + await this.loop({ sessionId }); + } else { + this.log(`Sync not enabled. Skipping Sonarr server: ${server.name}`); + } + } + + this.log('Sonarr sync complete', 'info'); + } catch (e) { + this.log('Something went wrong.', 'error', { errorMessage: e.message }); + } finally { + // If a new scanning session hasnt started, set running back to false + if (this.sessionId === sessionId) { + this.running = false; + } + } + } + + public status(): SyncStatus { + return { + running: this.running, + progress: this.progress, + total: this.items.length, + currentServer: this.currentServer, + servers: this.servers, + }; + } + + public cancel(): void { + this.running = false; + } + + private async processSonarrSeries(sonarrSeries: SonarrSeries) { + const mediaRepository = getRepository(Media); + const server4k = this.enable4k && this.currentServer.is4k; + + const media = await mediaRepository.findOne({ + where: { tvdbId: sonarrSeries.tvdbId }, + }); + + const currentSeasonsAvailable = (media?.seasons ?? []).filter( + (season) => + season[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE + ).length; + + const newSeasons: Season[] = []; + + for (const season of sonarrSeries.seasons) { + const existingSeason = media?.seasons.find( + (es) => es.seasonNumber === season.seasonNumber + ); + + // We are already tracking this season so we can work on it directly + if (existingSeason) { + if ( + existingSeason[server4k ? 'status4k' : 'status'] !== + MediaStatus.AVAILABLE && + season.statistics + ) { + existingSeason[server4k ? 'status4k' : 'status'] = + season.statistics.episodeFileCount === + season.statistics.totalEpisodeCount + ? MediaStatus.AVAILABLE + : season.statistics.episodeFileCount > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : season.monitored + ? MediaStatus.PROCESSING + : existingSeason[server4k ? 'status4k' : 'status']; + } + } else { + if (season.statistics && season.seasonNumber !== 0) { + const allEpisodes = + season.statistics.episodeFileCount === + season.statistics.totalEpisodeCount; + newSeasons.push( + new Season({ + seasonNumber: season.seasonNumber, + status: + !server4k && allEpisodes + ? MediaStatus.AVAILABLE + : !server4k && season.statistics.episodeFileCount > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : !server4k && season.monitored + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN, + status4k: + server4k && allEpisodes + ? MediaStatus.AVAILABLE + : server4k && season.statistics.episodeFileCount > 0 + ? MediaStatus.PARTIALLY_AVAILABLE + : !server4k && season.monitored + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN, + }) + ); + } + } + } + + const filteredSeasons = sonarrSeries.seasons.filter( + (s) => s.seasonNumber !== 0 + ); + + const isAllSeasons = + (media?.seasons ?? []).filter( + (s) => s[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE + ).length + + newSeasons.filter( + (s) => s[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE + ).length >= + filteredSeasons.length && filteredSeasons.length > 0; + + if (media) { + media.seasons = [...media.seasons, ...newSeasons]; + + const newSeasonsAvailable = (media?.seasons ?? []).filter( + (season) => + season[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE + ).length; + + if (newSeasonsAvailable > currentSeasonsAvailable) { + this.log( + `Detected ${newSeasonsAvailable - currentSeasonsAvailable} new ${ + server4k ? '4K ' : '' + }season(s) for ${sonarrSeries.title}`, + 'debug' + ); + media.lastSeasonChange = new Date(); + } + + if ( + media[server4k ? 'serviceId4k' : 'serviceId'] !== this.currentServer.id + ) { + media[server4k ? 'serviceId4k' : 'serviceId'] = this.currentServer.id; + this.log(`Updated service ID for media entity: ${sonarrSeries.title}`); + } + + if ( + media[server4k ? 'externalServiceId4k' : 'externalServiceId'] !== + sonarrSeries.id + ) { + media[server4k ? 'externalServiceId4k' : 'externalServiceId'] = + sonarrSeries.id; + this.log( + `Updated external service ID for media entity: ${sonarrSeries.title}` + ); + } + + if ( + media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !== + sonarrSeries.titleSlug + ) { + media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] = + sonarrSeries.titleSlug; + this.log( + `Updated external service slug for media entity: ${sonarrSeries.title}` + ); + } + + // If the show is already available, and there are no new seasons, dont adjust + // the status + const shouldStayAvailable = + media.status === MediaStatus.AVAILABLE && + newSeasons.filter( + (season) => + season[server4k ? 'status4k' : 'status'] !== MediaStatus.UNKNOWN + ).length === 0; + + media[server4k ? 'status4k' : 'status'] = + isAllSeasons || shouldStayAvailable + ? MediaStatus.AVAILABLE + : media.seasons.some( + (season) => season.status !== MediaStatus.UNKNOWN + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : MediaStatus.UNKNOWN; + + await mediaRepository.save(media); + } else { + const tmdb = new TheMovieDb(); + let tvShow: TmdbTvDetails; + + try { + tvShow = await tmdb.getShowByTvdbId({ + tvdbId: sonarrSeries.tvdbId, + }); + } catch (e) { + this.log( + 'Failed to create new media item during sync. TVDB ID is missing from TMDB?', + 'warn', + { sonarrSeries, errorMessage: e.message } + ); + return; + } + + const newMedia = new Media({ + tmdbId: tvShow.id, + tvdbId: sonarrSeries.tvdbId, + mediaType: MediaType.TV, + serviceId: !server4k ? this.currentServer.id : undefined, + serviceId4k: server4k ? this.currentServer.id : undefined, + externalServiceId: !server4k ? sonarrSeries.id : undefined, + externalServiceId4k: server4k ? sonarrSeries.id : undefined, + externalServiceSlug: !server4k ? sonarrSeries.titleSlug : undefined, + externalServiceSlug4k: server4k ? sonarrSeries.titleSlug : undefined, + seasons: newSeasons, + status: + !server4k && isAllSeasons + ? MediaStatus.AVAILABLE + : !server4k && + newSeasons.some( + (s) => + s.status === MediaStatus.PARTIALLY_AVAILABLE || + s.status === MediaStatus.AVAILABLE + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : !server4k + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN, + status4k: + server4k && isAllSeasons + ? MediaStatus.AVAILABLE + : server4k && + newSeasons.some( + (s) => + s.status4k === MediaStatus.PARTIALLY_AVAILABLE || + s.status4k === MediaStatus.AVAILABLE + ) + ? MediaStatus.PARTIALLY_AVAILABLE + : server4k + ? MediaStatus.PROCESSING + : MediaStatus.UNKNOWN, + }); + + this.log( + `Added media for series ${sonarrSeries.title} and set status to ${ + MediaStatus[newMedia[server4k ? 'status4k' : 'status']] + }` + ); + await mediaRepository.save(newMedia); + } + } + + private async processItems(items: SonarrSeries[]) { + await Promise.all( + items.map(async (sonarrSeries) => { + await this.processSonarrSeries(sonarrSeries); + }) + ); + } + + private async loop({ + start = 0, + end = BUNDLE_SIZE, + sessionId, + }: { + start?: number; + end?: number; + sessionId?: string; + } = {}) { + const slicedItems = this.items.slice(start, end); + + if (!this.running) { + throw new Error('Sync was aborted.'); + } + + if (this.sessionId !== sessionId) { + throw new Error('New session was started. Old session aborted.'); + } + + if (start < this.items.length) { + this.progress = start; + await this.processItems(slicedItems); + + await new Promise((resolve, reject) => + setTimeout(() => { + this.loop({ + start: start + BUNDLE_SIZE, + end: end + BUNDLE_SIZE, + sessionId, + }) + .then(() => resolve()) + .catch((e) => reject(new Error(e.message))); + }, UPDATE_RATE) + ); + } + } + + private log( + message: string, + level: 'info' | 'error' | 'debug' | 'warn' = 'debug', + optional?: Record + ): void { + logger[level](message, { label: 'Sonarr Sync', ...optional }); + } +} + +export const jobSonarrSync = new JobSonarrSync(); diff --git a/server/lib/downloadtracker.ts b/server/lib/downloadtracker.ts new file mode 100644 index 00000000..9faf411a --- /dev/null +++ b/server/lib/downloadtracker.ts @@ -0,0 +1,195 @@ +import { uniqWith } from 'lodash'; +import RadarrAPI from '../api/radarr'; +import SonarrAPI from '../api/sonarr'; +import { MediaType } from '../constants/media'; +import logger from '../logger'; +import { getSettings } from './settings'; + +export interface DownloadingItem { + mediaType: MediaType; + externalId: number; + size: number; + sizeLeft: number; + status: string; + timeLeft: string; + estimatedCompletionTime: Date; + title: string; +} + +class DownloadTracker { + private radarrServers: Record = {}; + private sonarrServers: Record = {}; + + public getMovieProgress( + serverId: number, + externalServiceId: number + ): DownloadingItem[] { + if (!this.radarrServers[serverId]) { + return []; + } + + return this.radarrServers[serverId].filter( + (item) => item.externalId === externalServiceId + ); + } + + public getSeriesProgress( + serverId: number, + externalServiceId: number + ): DownloadingItem[] { + if (!this.sonarrServers[serverId]) { + return []; + } + + return this.sonarrServers[serverId].filter( + (item) => item.externalId === externalServiceId + ); + } + + public async resetDownloadTracker() { + this.radarrServers = {}; + } + + public updateDownloads() { + this.updateRadarrDownloads(); + this.updateSonarrDownloads(); + } + + private async updateRadarrDownloads() { + const settings = getSettings(); + + // Remove duplicate servers + const filteredServers = uniqWith(settings.radarr, (radarrA, radarrB) => { + return ( + radarrA.hostname === radarrB.hostname && + radarrA.port === radarrB.port && + radarrA.baseUrl === radarrB.baseUrl + ); + }); + + // Load downloads from Radarr servers + Promise.all( + filteredServers.map(async (server) => { + if (server.syncEnabled) { + const radarr = new RadarrAPI({ + apiKey: server.apiKey, + url: RadarrAPI.buildRadarrUrl(server, '/api/v3'), + }); + + const queueItems = await radarr.getQueue(); + + this.radarrServers[server.id] = queueItems.map((item) => ({ + externalId: item.movieId, + estimatedCompletionTime: new Date(item.estimatedCompletionTime), + mediaType: MediaType.MOVIE, + size: item.size, + sizeLeft: item.sizeleft, + status: item.status, + timeLeft: item.timeleft, + title: item.title, + })); + + if (queueItems.length > 0) { + logger.debug( + `Found ${queueItems.length} item(s) in progress on Radarr server: ${server.name}`, + { label: 'Download Tracker' } + ); + } + + // Duplicate this data to matching servers + const matchingServers = settings.radarr.filter( + (rs) => + rs.hostname === server.hostname && + rs.port === server.port && + rs.baseUrl === server.baseUrl && + rs.id !== server.id + ); + + if (matchingServers.length > 0) { + logger.debug( + `Matching download data to ${matchingServers.length} other Radarr server(s)`, + { label: 'Download Tracker' } + ); + } + + matchingServers.forEach((ms) => { + if (ms.syncEnabled) { + this.radarrServers[ms.id] = this.radarrServers[server.id]; + } + }); + } + }) + ); + } + + private async updateSonarrDownloads() { + const settings = getSettings(); + + // Remove duplicate servers + const filteredServers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => { + return ( + sonarrA.hostname === sonarrB.hostname && + sonarrA.port === sonarrB.port && + sonarrA.baseUrl === sonarrB.baseUrl + ); + }); + + // Load downloads from Radarr servers + Promise.all( + filteredServers.map(async (server) => { + if (server.syncEnabled) { + const radarr = new SonarrAPI({ + apiKey: server.apiKey, + url: SonarrAPI.buildSonarrUrl(server, '/api/v3'), + }); + + const queueItems = await radarr.getQueue(); + + this.sonarrServers[server.id] = queueItems.map((item) => ({ + externalId: item.seriesId, + estimatedCompletionTime: new Date(item.estimatedCompletionTime), + mediaType: MediaType.TV, + size: item.size, + sizeLeft: item.sizeleft, + status: item.status, + timeLeft: item.timeleft, + title: item.title, + })); + + if (queueItems.length > 0) { + logger.debug( + `Found ${queueItems.length} item(s) in progress on Sonarr server: ${server.name}`, + { label: 'Download Tracker' } + ); + } + + // Duplicate this data to matching servers + const matchingServers = settings.sonarr.filter( + (rs) => + rs.hostname === server.hostname && + rs.port === server.port && + rs.baseUrl === server.baseUrl && + rs.id !== server.id + ); + + if (matchingServers.length > 0) { + logger.debug( + `Matching download data to ${matchingServers.length} other Sonarr server(s)`, + { label: 'Download Tracker' } + ); + } + + matchingServers.forEach((ms) => { + if (ms.syncEnabled) { + this.sonarrServers[ms.id] = this.sonarrServers[server.id]; + } + }); + } + }) + ); + } +} + +const downloadTracker = new DownloadTracker(); + +export default downloadTracker; diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index de6ed7ba..798f6525 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -104,7 +104,7 @@ class DiscordAgent fields.push( { name: 'Requested By', - value: payload.notifyUser.username ?? '', + value: payload.notifyUser.displayName ?? '', inline: true, }, { @@ -126,7 +126,7 @@ class DiscordAgent fields.push( { name: 'Requested By', - value: payload.notifyUser.username ?? '', + value: payload.notifyUser.displayName ?? '', inline: true, }, { @@ -148,7 +148,7 @@ class DiscordAgent fields.push( { name: 'Requested By', - value: payload.notifyUser.username ?? '', + value: payload.notifyUser.displayName ?? '', inline: true, }, { @@ -170,7 +170,7 @@ class DiscordAgent fields.push( { name: 'Requested By', - value: payload.notifyUser.username ?? '', + value: payload.notifyUser.displayName ?? '', inline: true, }, { diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index a74a4c18..ccb42802 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -60,7 +60,7 @@ class EmailAgent mediaName: payload.subject, imageUrl: payload.image, timestamp: new Date().toTimeString(), - requestedBy: payload.notifyUser.username, + requestedBy: payload.notifyUser.displayName, actionUrl: applicationUrl ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` : undefined, @@ -106,7 +106,7 @@ class EmailAgent mediaName: payload.subject, imageUrl: payload.image, timestamp: new Date().toTimeString(), - requestedBy: payload.notifyUser.username, + requestedBy: payload.notifyUser.displayName, actionUrl: applicationUrl ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` : undefined, @@ -144,7 +144,7 @@ class EmailAgent mediaName: payload.subject, imageUrl: payload.image, timestamp: new Date().toTimeString(), - requestedBy: payload.notifyUser.username, + requestedBy: payload.notifyUser.displayName, actionUrl: applicationUrl ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` : undefined, @@ -181,7 +181,7 @@ class EmailAgent mediaName: payload.subject, imageUrl: payload.image, timestamp: new Date().toTimeString(), - requestedBy: payload.notifyUser.username, + requestedBy: payload.notifyUser.displayName, actionUrl: applicationUrl ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` : undefined, @@ -218,7 +218,7 @@ class EmailAgent mediaName: payload.subject, imageUrl: payload.image, timestamp: new Date().toTimeString(), - requestedBy: payload.notifyUser.username, + requestedBy: payload.notifyUser.displayName, actionUrl: applicationUrl ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` : undefined, diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts index 158d01c7..5b7713b0 100644 --- a/server/lib/notifications/agents/pushover.ts +++ b/server/lib/notifications/agents/pushover.ts @@ -48,42 +48,42 @@ class PushoverAgent const title = payload.subject; const plot = payload.message; - const user = payload.notifyUser.username; + const username = payload.notifyUser.displayName; switch (type) { case Notification.MEDIA_PENDING: messageTitle = 'New Request'; message += `${title}\n\n`; message += `${plot}\n\n`; - message += `Requested By\n${user}\n\n`; + message += `Requested By\n${username}\n\n`; message += `Status\nPending Approval\n`; break; case Notification.MEDIA_APPROVED: messageTitle = 'Request Approved'; message += `${title}\n\n`; message += `${plot}\n\n`; - message += `Requested By\n${user}\n\n`; + message += `Requested By\n${username}\n\n`; message += `Status\nProcessing Request\n`; break; case Notification.MEDIA_AVAILABLE: messageTitle = 'Now available!'; message += `${title}\n\n`; message += `${plot}\n\n`; - message += `Requested By\n${user}\n\n`; + message += `Requested By\n${username}\n\n`; message += `Status\nAvailable\n`; break; case Notification.MEDIA_DECLINED: messageTitle = 'Request Declined'; message += `${title}\n\n`; message += `${plot}\n\n`; - message += `Requested By\n${user}\n\n`; + message += `Requested By\n${username}\n\n`; message += `Status\nDeclined\n`; break; case Notification.TEST_NOTIFICATION: messageTitle = 'Test Notification'; message += `${title}\n\n`; message += `${plot}\n\n`; - message += `Requested By\n${user}\n`; + message += `Requested By\n${username}\n`; break; } diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts index df338884..f6ca6856 100644 --- a/server/lib/notifications/agents/slack.ts +++ b/server/lib/notifications/agents/slack.ts @@ -69,7 +69,7 @@ class SlackAgent fields.push( { type: 'mrkdwn', - text: `*Requested By*\n${payload.notifyUser.username ?? ''}`, + text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`, }, { type: 'mrkdwn', @@ -85,7 +85,7 @@ class SlackAgent fields.push( { type: 'mrkdwn', - text: `*Requested By*\n${payload.notifyUser.username ?? ''}`, + text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`, }, { type: 'mrkdwn', @@ -101,7 +101,7 @@ class SlackAgent fields.push( { type: 'mrkdwn', - text: `*Requested By*\n${payload.notifyUser.username ?? ''}`, + text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`, }, { type: 'mrkdwn', @@ -117,7 +117,7 @@ class SlackAgent fields.push( { type: 'mrkdwn', - text: `*Requested By*\n${payload.notifyUser.username ?? ''}`, + text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`, }, { type: 'mrkdwn', diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index 62de93ec..a2b09c1c 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -51,7 +51,7 @@ class TelegramAgent const title = this.escapeText(payload.subject); const plot = this.escapeText(payload.message); - const user = this.escapeText(payload.notifyUser.username); + const user = this.escapeText(payload.notifyUser.displayName); /* eslint-disable no-useless-escape */ switch (type) { diff --git a/server/lib/notifications/agents/webhook.ts b/server/lib/notifications/agents/webhook.ts index d0e502e8..796593da 100644 --- a/server/lib/notifications/agents/webhook.ts +++ b/server/lib/notifications/agents/webhook.ts @@ -16,7 +16,7 @@ const KeyMap: Record = { subject: 'subject', message: 'message', image: 'image', - notifyuser_username: 'notifyUser.username', + notifyuser_username: 'notifyUser.displayName', notifyuser_email: 'notifyUser.email', notifyuser_avatar: 'notifyUser.avatar', media_tmdbid: 'media.tmdbId', diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts index 23fba116..d4341217 100644 --- a/server/lib/notifications/index.ts +++ b/server/lib/notifications/index.ts @@ -1,4 +1,5 @@ import logger from '../../logger'; +import { getSettings } from '../settings'; import type { NotificationAgent, NotificationPayload } from './agents/agent'; export enum Notification { @@ -43,11 +44,12 @@ class NotificationManager { type: Notification, payload: NotificationPayload ): void { + const settings = getSettings().notifications; logger.info(`Sending notification for ${Notification[type]}`, { label: 'Notifications', }); this.activeAgents.forEach((agent) => { - if (agent.shouldSend(type)) { + if (settings.enabled && agent.shouldSend(type)) { agent.send(type, payload); } }); diff --git a/server/lib/settings.ts b/server/lib/settings.ts index b9ad92a9..1209ec30 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -32,6 +32,9 @@ interface DVRSettings { activeDirectory: string; is4k: boolean; isDefault: boolean; + externalUrl?: string; + syncEnabled: boolean; + preventSearch: boolean; } export interface RadarrSettings extends DVRSettings { @@ -48,7 +51,10 @@ export interface SonarrSettings extends DVRSettings { export interface MainSettings { apiKey: string; applicationUrl: string; + csrfProtection: boolean; defaultPermissions: number; + hideAvailable: boolean; + trustProxy: boolean; } interface PublicSettings { @@ -58,6 +64,7 @@ interface PublicSettings { interface FullPublicSettings extends PublicSettings { movie4kEnabled: boolean; series4kEnabled: boolean; + hideAvailable: boolean; } export interface NotificationAgentConfig { @@ -124,6 +131,8 @@ interface NotificationAgents { } interface NotificationSettings { + enabled: boolean; + autoapprovalEnabled: boolean; agents: NotificationAgents; } @@ -150,7 +159,10 @@ class Settings { main: { apiKey: '', applicationUrl: '', + csrfProtection: false, defaultPermissions: Permission.REQUEST, + hideAvailable: false, + trustProxy: false, }, plex: { name: '', @@ -165,6 +177,8 @@ class Settings { initialized: false, }, notifications: { + enabled: true, + autoapprovalEnabled: false, agents: { email: { enabled: false, @@ -281,6 +295,7 @@ class Settings { series4kEnabled: this.data.sonarr.some( (sonarr) => sonarr.is4k && sonarr.isDefault ), + hideAvailable: this.data.main.hideAvailable, }; } diff --git a/server/migration/1611508672722-AddDisplayNameToUser.ts b/server/migration/1611508672722-AddDisplayNameToUser.ts new file mode 100644 index 00000000..cacea059 --- /dev/null +++ b/server/migration/1611508672722-AddDisplayNameToUser.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDisplayNameToUser1611508672722 implements MigrationInterface { + name = 'AddDisplayNameToUser1611508672722'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "username" FROM "user"` + ); + await queryRunner.query(`DROP TABLE "user"`); + await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); + await queryRunner.query( + `CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "user"` + ); + await queryRunner.query(`DROP TABLE "user"`); + await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); + await queryRunner.query( + `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "temporary_user"` + ); + await queryRunner.query(`DROP TABLE "temporary_user"`); + await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); + await queryRunner.query( + `CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType" FROM "temporary_user"` + ); + await queryRunner.query(`DROP TABLE "temporary_user"`); + } +} diff --git a/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts b/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts new file mode 100644 index 00000000..49f47e40 --- /dev/null +++ b/server/migration/1611757511674-SonarrRadarrSyncServiceFields.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SonarrRadarrSyncServiceFields1611757511674 + implements MigrationInterface { + name = 'SonarrRadarrSyncServiceFields1611757511674'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query( + `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt" FROM "media"` + ); + await queryRunner.query(`DROP TABLE "media"`); + await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); + await queryRunner.query( + `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + ); + await queryRunner.query( + `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt" FROM "temporary_media"` + ); + await queryRunner.query(`DROP TABLE "temporary_media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + } +} diff --git a/server/migration/1611801511397-AddRatingKeysToMedia.ts b/server/migration/1611801511397-AddRatingKeysToMedia.ts new file mode 100644 index 00000000..f9865c8f --- /dev/null +++ b/server/migration/1611801511397-AddRatingKeysToMedia.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRatingKeysToMedia1611801511397 implements MigrationInterface { + name = 'AddRatingKeysToMedia1611801511397'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query( + `CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k" FROM "media"` + ); + await queryRunner.query(`DROP TABLE "media"`); + await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`); + await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`); + await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`); + await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`); + await queryRunner.query( + `CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))` + ); + await queryRunner.query( + `INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k" FROM "temporary_media"` + ); + await queryRunner.query(`DROP TABLE "temporary_media"`); + await queryRunner.query( + `CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") ` + ); + } +} diff --git a/server/models/Movie.ts b/server/models/Movie.ts index a9367d73..c8639613 100644 --- a/server/models/Movie.ts +++ b/server/models/Movie.ts @@ -72,6 +72,7 @@ export interface MovieDetails { }; mediaInfo?: Media; externalIds: ExternalIds; + plexUrl?: string; } export const mapMovieDetails = ( diff --git a/server/routes/auth.ts b/server/routes/auth.ts index 5f60d512..f1b68d34 100644 --- a/server/routes/auth.ts +++ b/server/routes/auth.ts @@ -48,13 +48,17 @@ authRoutes.post('/login', async (req, res, next) => { // Let's check if their plex token is up to date if (user.plexToken !== body.authToken) { user.plexToken = body.authToken; - await userRepository.save(user); } // Update the users avatar with their plex thumbnail (incase it changed) user.avatar = account.thumb; user.email = account.email; - user.username = account.username; + user.plexUsername = account.username; + + if (user.username === account.username) { + user.username = ''; + } + await userRepository.save(user); } else { // Here we check if it's the first user. If it is, we create the user with no check // and give them admin permissions @@ -63,7 +67,7 @@ authRoutes.post('/login', async (req, res, next) => { if (totalUsers === 0) { user = new User({ email: account.email, - username: account.username, + plexUsername: account.username, plexId: account.id, plexToken: account.authToken, permissions: Permission.ADMIN, @@ -86,7 +90,7 @@ authRoutes.post('/login', async (req, res, next) => { if (await mainPlexTv.checkUserAccess(account)) { user = new User({ email: account.email, - username: account.username, + plexUsername: account.username, plexId: account.id, plexToken: account.authToken, permissions: settings.main.defaultPermissions, @@ -141,7 +145,7 @@ authRoutes.post('/local', async (req, res, next) => { try { const user = await userRepository.findOne({ select: ['id', 'password'], - where: { email: body.email, userType: UserType.LOCAL }, + where: { email: body.email }, }); const isCorrectCredentials = await user?.passwordMatch(body.password); @@ -151,6 +155,7 @@ authRoutes.post('/local', async (req, res, next) => { logger.info('Failed login attempt from user with incorrect credentials', { label: 'Auth', account: { + ip: req.ip, email: body.email, password: '__REDACTED__', }, @@ -181,7 +186,7 @@ authRoutes.get('/logout', (req, res, next) => { if (err) { return next({ status: 500, - message: 'Something went wrong while attempting to logout', + message: 'Something went wrong while attempting to sign out.', }); } diff --git a/server/routes/media.ts b/server/routes/media.ts index f7d67d5c..f6c6a505 100644 --- a/server/routes/media.ts +++ b/server/routes/media.ts @@ -1,7 +1,7 @@ import { Router } from 'express'; -import { getRepository, FindOperator, FindOneOptions } from 'typeorm'; +import { getRepository, FindOperator, FindOneOptions, In } from 'typeorm'; import Media from '../entity/Media'; -import { MediaStatus } from '../constants/media'; +import { MediaStatus, MediaType } from '../constants/media'; import logger from '../logger'; import { isAuthenticated } from '../middleware/auth'; import { Permission } from '../lib/permissions'; @@ -27,6 +27,12 @@ mediaRoutes.get('/', async (req, res, next) => { case 'partial': statusFilter = MediaStatus.PARTIALLY_AVAILABLE; break; + case 'allavailable': + statusFilter = In([ + MediaStatus.AVAILABLE, + MediaStatus.PARTIALLY_AVAILABLE, + ]); + break; case 'processing': statusFilter = MediaStatus.PROCESSING; break; @@ -76,6 +82,63 @@ mediaRoutes.get('/', async (req, res, next) => { } }); +mediaRoutes.get< + { + id: string; + status: 'available' | 'partial' | 'processing' | 'pending' | 'unknown'; + }, + Media +>( + '/:id/:status', + isAuthenticated(Permission.MANAGE_REQUESTS), + async (req, res, next) => { + const mediaRepository = getRepository(Media); + + const media = await mediaRepository.findOne({ + where: { id: Number(req.params.id) }, + }); + + if (!media) { + return next({ status: 404, message: 'Media does not exist.' }); + } + + const is4k = Boolean(req.query.is4k); + + switch (req.params.status) { + case 'available': + media[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE; + if (media.mediaType === MediaType.TV) { + // Mark all seasons available + media.seasons.forEach((season) => { + season[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE; + }); + } + break; + case 'partial': + if (media.mediaType === MediaType.MOVIE) { + return next({ + status: 400, + message: 'Only series can be set to be partially available', + }); + } + media.status = MediaStatus.PARTIALLY_AVAILABLE; + break; + case 'processing': + media.status = MediaStatus.PROCESSING; + break; + case 'pending': + media.status = MediaStatus.PENDING; + break; + case 'unknown': + media.status = MediaStatus.UNKNOWN; + } + + await mediaRepository.save(media); + + return res.status(200).json(media); + } +); + mediaRoutes.delete( '/:id', isAuthenticated(Permission.MANAGE_REQUESTS), diff --git a/server/routes/request.ts b/server/routes/request.ts index 0b726724..2a5e7c41 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -109,25 +109,45 @@ requestRoutes.post( if (!media) { media = new Media({ tmdbId: tmdbMedia.id, - tvdbId: tmdbMedia.external_ids.tvdb_id, + tvdbId: req.body.tvdbId ?? tmdbMedia.external_ids.tvdb_id, status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, mediaType: req.body.mediaType, }); - await mediaRepository.save(media); } else { if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) { media.status = MediaStatus.PENDING; - await mediaRepository.save(media); } if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) { media.status4k = MediaStatus.PENDING; - await mediaRepository.save(media); } } if (req.body.mediaType === 'movie') { + const existing = await requestRepository.findOne({ + where: { + media: { + tmdbId: tmdbMedia.id, + }, + requestedBy: req.user, + is4k: req.body.is4k, + }, + }); + + if (existing) { + logger.warn('Duplicate request for media blocked', { + tmdbId: tmdbMedia.id, + mediaType: req.body.mediaType, + }); + return next({ + status: 409, + message: 'Request for this media already exists.', + }); + } + + await mediaRepository.save(media); + const request = new MediaRequest({ type: MediaType.MOVIE, media, @@ -185,6 +205,8 @@ requestRoutes.post( }); } + await mediaRepository.save(media); + const request = new MediaRequest({ type: MediaType.TV, media: { diff --git a/server/routes/service.ts b/server/routes/service.ts index c163a940..94b2bc72 100644 --- a/server/routes/service.ts +++ b/server/routes/service.ts @@ -6,6 +6,8 @@ import { ServiceCommonServerWithDetails, } from '../interfaces/api/serviceInterfaces'; import { getSettings } from '../lib/settings'; +import TheMovieDb from '../api/themoviedb'; +import logger from '../logger'; const serviceRoutes = Router(); @@ -100,13 +102,13 @@ serviceRoutes.get<{ sonarrId: string }>( const settings = getSettings(); const sonarrSettings = settings.sonarr.find( - (radarr) => radarr.id === Number(req.params.sonarrId) + (sonarr) => sonarr.id === Number(req.params.sonarrId) ); if (!sonarrSettings) { return next({ status: 404, - message: 'Radarr server with provided ID does not exist.', + message: 'Sonarr server with provided ID does not exist.', }); } @@ -145,4 +147,52 @@ serviceRoutes.get<{ sonarrId: string }>( } ); +serviceRoutes.get<{ tmdbId: string }>( + '/sonarr/lookup/:tmdbId', + async (req, res, next) => { + const settings = getSettings(); + const tmdb = new TheMovieDb(); + + const sonarrSettings = settings.sonarr[0]; + + if (!sonarrSettings) { + logger.error('No sonarr server has been setup', { + label: 'Media Request', + }); + return next({ + status: 404, + message: 'No sonarr server has been setup', + }); + } + + const sonarr = new SonarrAPI({ + apiKey: sonarrSettings.apiKey, + url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${ + sonarrSettings.hostname + }:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`, + }); + + try { + const tv = await tmdb.getTvShow({ + tvId: Number(req.params.tmdbId), + language: req.query.language as string, + }); + + const response = await sonarr.getSeriesByTitle(tv.name); + + return res.status(200).json(response); + } catch (e) { + logger.error('Failed to fetch tvdb search results', { + label: 'Media Request', + message: e.message, + }); + + return next({ + status: 500, + message: 'Something went wrong trying to fetch series information', + }); + } + } +); + export default serviceRoutes; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index 0b6ebaf4..1d87c12e 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -1,18 +1,10 @@ import { Router } from 'express'; -import { - getSettings, - RadarrSettings, - SonarrSettings, - Library, - MainSettings, -} from '../../lib/settings'; +import { getSettings, Library, MainSettings } from '../../lib/settings'; import { getRepository } from 'typeorm'; import { User } from '../../entity/User'; import PlexAPI from '../../api/plexapi'; +import PlexTvAPI from '../../api/plextv'; import { jobPlexFullSync } from '../../job/plexsync'; -import SonarrAPI from '../../api/sonarr'; -import RadarrAPI from '../../api/radarr'; -import logger from '../../logger'; import { scheduledJobs } from '../../job/schedule'; import { Permission } from '../../lib/permissions'; import { isAuthenticated } from '../../middleware/auth'; @@ -22,10 +14,14 @@ import { MediaRequest } from '../../entity/MediaRequest'; import { getAppVersion } from '../../utils/appVersion'; import { SettingsAboutResponse } from '../../interfaces/api/settingsInterfaces'; import notificationRoutes from './notifications'; +import sonarrRoutes from './sonarr'; +import radarrRoutes from './radarr'; const settingsRoutes = Router(); settingsRoutes.use('/notifications', notificationRoutes); +settingsRoutes.use('/radarr', radarrRoutes); +settingsRoutes.use('/sonarr', sonarrRoutes); const filteredMainSettings = ( user: User, @@ -106,6 +102,69 @@ settingsRoutes.post('/plex', async (req, res, next) => { return res.status(200).json(settings.plex); }); +settingsRoutes.get('/plex/devices/servers', async (req, res, next) => { + const userRepository = getRepository(User); + const regexp = /(http(s?):\/\/)(.*)(:[0-9]*)/; + try { + const admin = await userRepository.findOneOrFail({ + select: ['id', 'plexToken'], + order: { id: 'ASC' }, + }); + const plexTvClient = admin.plexToken + ? new PlexTvAPI(admin.plexToken) + : null; + const devices = (await plexTvClient?.getDevices())?.filter((device) => { + return device.provides.includes('server') && device.owned; + }); + const settings = getSettings(); + if (devices) { + await Promise.all( + devices.map(async (device) => { + await Promise.all( + device.connection.map(async (connection) => { + connection.host = connection.uri.replace(regexp, '$3'); + let msg: + | { status: number; message: string } + | undefined = undefined; + const plexDeviceSettings = { + ...settings.plex, + ip: connection.host, + port: connection.port, + useSsl: connection.protocol === 'https' ? true : false, + }; + const plexClient = new PlexAPI({ + plexToken: admin.plexToken, + plexSettings: plexDeviceSettings, + timeout: 5000, + }); + try { + await plexClient.getStatus(); + msg = { + status: 200, + message: 'OK', + }; + } catch (e) { + msg = { + status: 500, + message: e.message, + }; + } + connection.status = msg?.status; + connection.message = msg?.message; + }) + ); + }) + ); + } + return res.status(200).json(devices); + } catch (e) { + return next({ + status: 500, + message: `Failed to connect to Plex: ${e.message}`, + }); + } +}); + settingsRoutes.get('/plex/library', async (req, res) => { const settings = getSettings(); @@ -156,271 +215,64 @@ settingsRoutes.get('/plex/sync', (req, res) => { } else if (req.query.start) { jobPlexFullSync.run(); } - return res.status(200).json(jobPlexFullSync.status()); }); -settingsRoutes.get('/radarr', (_req, res) => { - const settings = getSettings(); - - res.status(200).json(settings.radarr); -}); - -settingsRoutes.post('/radarr', (req, res) => { - const settings = getSettings(); - - const newRadarr = req.body as RadarrSettings; - const lastItem = settings.radarr[settings.radarr.length - 1]; - newRadarr.id = lastItem ? lastItem.id + 1 : 0; - - // If we are setting this as the default, clear any previous defaults for the same type first - // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true - // and are the default - if (req.body.isDefault) { - settings.radarr - .filter((radarrInstance) => radarrInstance.is4k === req.body.is4k) - .forEach((radarrInstance) => { - radarrInstance.isDefault = false; - }); - } - - settings.radarr = [...settings.radarr, newRadarr]; - settings.save(); - - return res.status(201).json(newRadarr); -}); - -settingsRoutes.post('/radarr/test', async (req, res, next) => { - try { - const radarr = new RadarrAPI({ - apiKey: req.body.apiKey, - url: `${req.body.useSsl ? 'https' : 'http'}://${req.body.hostname}:${ - req.body.port - }${req.body.baseUrl ?? ''}/api`, - }); - - const profiles = await radarr.getProfiles(); - const folders = await radarr.getRootFolders(); - - return res.status(200).json({ - profiles, - rootFolders: folders.map((folder) => ({ - id: folder.id, - path: folder.path, - })), - }); - } catch (e) { - logger.error('Failed to test Radarr', { - label: 'Radarr', - message: e.message, - }); - - next({ status: 500, message: 'Failed to connect to Radarr' }); - } -}); - -settingsRoutes.put<{ id: string }>('/radarr/:id', (req, res) => { - const settings = getSettings(); - - const radarrIndex = settings.radarr.findIndex( - (r) => r.id === Number(req.params.id) - ); - - if (radarrIndex === -1) { - return res - .status(404) - .json({ status: '404', message: 'Settings instance not found' }); - } - - // If we are setting this as the default, clear any previous defaults for the same type first - // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true - // and are the default - if (req.body.isDefault) { - settings.radarr - .filter((radarrInstance) => radarrInstance.is4k === req.body.is4k) - .forEach((radarrInstance) => { - radarrInstance.isDefault = false; - }); - } - - settings.radarr[radarrIndex] = { - ...req.body, - id: Number(req.params.id), - } as RadarrSettings; - settings.save(); - - return res.status(200).json(settings.radarr[radarrIndex]); -}); - -settingsRoutes.get<{ id: string }>('/radarr/:id/profiles', async (req, res) => { - const settings = getSettings(); - - const radarrSettings = settings.radarr.find( - (r) => r.id === Number(req.params.id) - ); - - if (!radarrSettings) { - return res - .status(404) - .json({ status: '404', message: 'Settings instance not found' }); - } - - const radarr = new RadarrAPI({ - apiKey: radarrSettings.apiKey, - url: `${radarrSettings.useSsl ? 'https' : 'http'}://${ - radarrSettings.hostname - }:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`, - }); - - const profiles = await radarr.getProfiles(); - - return res.status(200).json( - profiles.map((profile) => ({ - id: profile.id, - name: profile.name, - })) - ); -}); - -settingsRoutes.delete<{ id: string }>('/radarr/:id', (req, res) => { - const settings = getSettings(); - - const radarrIndex = settings.radarr.findIndex( - (r) => r.id === Number(req.params.id) - ); - - if (radarrIndex === -1) { - return res - .status(404) - .json({ status: '404', message: 'Settings instance not found' }); - } - - const removed = settings.radarr.splice(radarrIndex, 1); - settings.save(); - - return res.status(200).json(removed[0]); -}); - -settingsRoutes.get('/sonarr', (_req, res) => { - const settings = getSettings(); - - res.status(200).json(settings.sonarr); -}); - -settingsRoutes.post('/sonarr', (req, res) => { - const settings = getSettings(); - - const newSonarr = req.body as SonarrSettings; - const lastItem = settings.sonarr[settings.sonarr.length - 1]; - newSonarr.id = lastItem ? lastItem.id + 1 : 0; - - // If we are setting this as the default, clear any previous defaults for the same type first - // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true - // and are the default - if (req.body.isDefault) { - settings.sonarr - .filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k) - .forEach((sonarrInstance) => { - sonarrInstance.isDefault = false; - }); - } - - settings.sonarr = [...settings.sonarr, newSonarr]; - settings.save(); - - return res.status(201).json(newSonarr); -}); - -settingsRoutes.post('/sonarr/test', async (req, res, next) => { - try { - const sonarr = new SonarrAPI({ - apiKey: req.body.apiKey, - url: `${req.body.useSsl ? 'https' : 'http'}://${req.body.hostname}:${ - req.body.port - }${req.body.baseUrl ?? ''}/api`, - }); - - const profiles = await sonarr.getProfiles(); - const folders = await sonarr.getRootFolders(); - - return res.status(200).json({ - profiles, - rootFolders: folders.map((folder) => ({ - id: folder.id, - path: folder.path, - })), - }); - } catch (e) { - logger.error('Failed to test Sonarr', { - label: 'Sonarr', - message: e.message, - }); - - next({ status: 500, message: 'Failed to connect to Sonarr' }); - } -}); - -settingsRoutes.put<{ id: string }>('/sonarr/:id', (req, res) => { - const settings = getSettings(); - - const sonarrIndex = settings.sonarr.findIndex( - (r) => r.id === Number(req.params.id) - ); - - if (sonarrIndex === -1) { - return res - .status(404) - .json({ status: '404', message: 'Settings instance not found' }); - } - - // If we are setting this as the default, clear any previous defaults for the same type first - // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true - // and are the default - if (req.body.isDefault) { - settings.sonarr - .filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k) - .forEach((sonarrInstance) => { - sonarrInstance.isDefault = false; - }); - } - - settings.sonarr[sonarrIndex] = { - ...req.body, - id: Number(req.params.id), - } as SonarrSettings; - settings.save(); - - return res.status(200).json(settings.sonarr[sonarrIndex]); -}); - -settingsRoutes.delete<{ id: string }>('/sonarr/:id', (req, res) => { - const settings = getSettings(); - - const sonarrIndex = settings.sonarr.findIndex( - (r) => r.id === Number(req.params.id) - ); - - if (sonarrIndex === -1) { - return res - .status(404) - .json({ status: '404', message: 'Settings instance not found' }); - } - - const removed = settings.sonarr.splice(sonarrIndex, 1); - settings.save(); - - return res.status(200).json(removed[0]); -}); - settingsRoutes.get('/jobs', (_req, res) => { return res.status(200).json( scheduledJobs.map((job) => ({ + id: job.id, name: job.name, + type: job.type, nextExecutionTime: job.job.nextInvocation(), + running: job.running ? job.running() : false, })) ); }); +settingsRoutes.get<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => { + const scheduledJob = scheduledJobs.find((job) => job.id === req.params.jobId); + + if (!scheduledJob) { + return next({ status: 404, message: 'Job not found' }); + } + + scheduledJob.job.invoke(); + + return res.status(200).json({ + id: scheduledJob.id, + name: scheduledJob.name, + type: scheduledJob.type, + nextExecutionTime: scheduledJob.job.nextInvocation(), + running: scheduledJob.running ? scheduledJob.running() : false, + }); +}); + +settingsRoutes.get<{ jobId: string }>( + '/jobs/:jobId/cancel', + (req, res, next) => { + const scheduledJob = scheduledJobs.find( + (job) => job.id === req.params.jobId + ); + + if (!scheduledJob) { + return next({ status: 404, message: 'Job not found' }); + } + + if (scheduledJob.cancelFn) { + scheduledJob.cancelFn(); + } + + return res.status(200).json({ + id: scheduledJob.id, + name: scheduledJob.name, + type: scheduledJob.type, + nextExecutionTime: scheduledJob.job.nextInvocation(), + running: scheduledJob.running ? scheduledJob.running() : false, + }); + } +); + settingsRoutes.get( '/initialize', isAuthenticated(Permission.ADMIN), diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts index 10b0e7a5..7f52e7db 100644 --- a/server/routes/settings/notifications.ts +++ b/server/routes/settings/notifications.ts @@ -10,6 +10,29 @@ import WebhookAgent from '../../lib/notifications/agents/webhook'; const notificationRoutes = Router(); +notificationRoutes.get('/', (_req, res) => { + const settings = getSettings().notifications; + return res.status(200).json({ + enabled: settings.enabled, + autoapprovalEnabled: settings.autoapprovalEnabled, + }); +}); + +notificationRoutes.post('/', (req, res) => { + const settings = getSettings(); + + Object.assign(settings.notifications, { + enabled: req.body.enabled, + autoapprovalEnabled: req.body.autoapprovalEnabled, + }); + settings.save(); + + return res.status(200).json({ + enabled: settings.notifications.enabled, + autoapprovalEnabled: settings.notifications.autoapprovalEnabled, + }); +}); + notificationRoutes.get('/discord', (_req, res) => { const settings = getSettings(); diff --git a/server/routes/settings/radarr.ts b/server/routes/settings/radarr.ts new file mode 100644 index 00000000..1bbcf208 --- /dev/null +++ b/server/routes/settings/radarr.ts @@ -0,0 +1,149 @@ +import { Router } from 'express'; +import RadarrAPI from '../../api/radarr'; +import { getSettings, RadarrSettings } from '../../lib/settings'; +import logger from '../../logger'; + +const radarrRoutes = Router(); + +radarrRoutes.get('/', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.radarr); +}); + +radarrRoutes.post('/', (req, res) => { + const settings = getSettings(); + + const newRadarr = req.body as RadarrSettings; + const lastItem = settings.radarr[settings.radarr.length - 1]; + newRadarr.id = lastItem ? lastItem.id + 1 : 0; + + // If we are setting this as the default, clear any previous defaults for the same type first + // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true + // and are the default + if (req.body.isDefault) { + settings.radarr + .filter((radarrInstance) => radarrInstance.is4k === req.body.is4k) + .forEach((radarrInstance) => { + radarrInstance.isDefault = false; + }); + } + + settings.radarr = [...settings.radarr, newRadarr]; + settings.save(); + + return res.status(201).json(newRadarr); +}); + +radarrRoutes.post('/test', async (req, res, next) => { + try { + const radarr = new RadarrAPI({ + apiKey: req.body.apiKey, + url: `${req.body.useSsl ? 'https' : 'http'}://${req.body.hostname}:${ + req.body.port + }${req.body.baseUrl ?? ''}/api`, + }); + + const profiles = await radarr.getProfiles(); + const folders = await radarr.getRootFolders(); + + return res.status(200).json({ + profiles, + rootFolders: folders.map((folder) => ({ + id: folder.id, + path: folder.path, + })), + }); + } catch (e) { + logger.error('Failed to test Radarr', { + label: 'Radarr', + message: e.message, + }); + + next({ status: 500, message: 'Failed to connect to Radarr' }); + } +}); + +radarrRoutes.put<{ id: string }>('/:id', (req, res) => { + const settings = getSettings(); + + const radarrIndex = settings.radarr.findIndex( + (r) => r.id === Number(req.params.id) + ); + + if (radarrIndex === -1) { + return res + .status(404) + .json({ status: '404', message: 'Settings instance not found' }); + } + + // If we are setting this as the default, clear any previous defaults for the same type first + // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true + // and are the default + if (req.body.isDefault) { + settings.radarr + .filter((radarrInstance) => radarrInstance.is4k === req.body.is4k) + .forEach((radarrInstance) => { + radarrInstance.isDefault = false; + }); + } + + settings.radarr[radarrIndex] = { + ...req.body, + id: Number(req.params.id), + } as RadarrSettings; + settings.save(); + + return res.status(200).json(settings.radarr[radarrIndex]); +}); + +radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => { + const settings = getSettings(); + + const radarrSettings = settings.radarr.find( + (r) => r.id === Number(req.params.id) + ); + + if (!radarrSettings) { + return res + .status(404) + .json({ status: '404', message: 'Settings instance not found' }); + } + + const radarr = new RadarrAPI({ + apiKey: radarrSettings.apiKey, + url: `${radarrSettings.useSsl ? 'https' : 'http'}://${ + radarrSettings.hostname + }:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`, + }); + + const profiles = await radarr.getProfiles(); + + return res.status(200).json( + profiles.map((profile) => ({ + id: profile.id, + name: profile.name, + })) + ); +}); + +radarrRoutes.delete<{ id: string }>('/:id', (req, res) => { + const settings = getSettings(); + + const radarrIndex = settings.radarr.findIndex( + (r) => r.id === Number(req.params.id) + ); + + if (radarrIndex === -1) { + return res + .status(404) + .json({ status: '404', message: 'Settings instance not found' }); + } + + const removed = settings.radarr.splice(radarrIndex, 1); + settings.save(); + + return res.status(200).json(removed[0]); +}); + +export default radarrRoutes; diff --git a/server/routes/settings/sonarr.ts b/server/routes/settings/sonarr.ts new file mode 100644 index 00000000..409530f7 --- /dev/null +++ b/server/routes/settings/sonarr.ts @@ -0,0 +1,119 @@ +import { Router } from 'express'; +import SonarrAPI from '../../api/sonarr'; +import { getSettings, SonarrSettings } from '../../lib/settings'; +import logger from '../../logger'; + +const sonarrRoutes = Router(); + +sonarrRoutes.get('/', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.sonarr); +}); + +sonarrRoutes.post('/', (req, res) => { + const settings = getSettings(); + + const newSonarr = req.body as SonarrSettings; + const lastItem = settings.sonarr[settings.sonarr.length - 1]; + newSonarr.id = lastItem ? lastItem.id + 1 : 0; + + // If we are setting this as the default, clear any previous defaults for the same type first + // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true + // and are the default + if (req.body.isDefault) { + settings.sonarr + .filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k) + .forEach((sonarrInstance) => { + sonarrInstance.isDefault = false; + }); + } + + settings.sonarr = [...settings.sonarr, newSonarr]; + settings.save(); + + return res.status(201).json(newSonarr); +}); + +sonarrRoutes.post('/test', async (req, res, next) => { + try { + const sonarr = new SonarrAPI({ + apiKey: req.body.apiKey, + url: `${req.body.useSsl ? 'https' : 'http'}://${req.body.hostname}:${ + req.body.port + }${req.body.baseUrl ?? ''}/api`, + }); + + const profiles = await sonarr.getProfiles(); + const folders = await sonarr.getRootFolders(); + + return res.status(200).json({ + profiles, + rootFolders: folders.map((folder) => ({ + id: folder.id, + path: folder.path, + })), + }); + } catch (e) { + logger.error('Failed to test Sonarr', { + label: 'Sonarr', + message: e.message, + }); + + next({ status: 500, message: 'Failed to connect to Sonarr' }); + } +}); + +sonarrRoutes.put<{ id: string }>('/:id', (req, res) => { + const settings = getSettings(); + + const sonarrIndex = settings.sonarr.findIndex( + (r) => r.id === Number(req.params.id) + ); + + if (sonarrIndex === -1) { + return res + .status(404) + .json({ status: '404', message: 'Settings instance not found' }); + } + + // If we are setting this as the default, clear any previous defaults for the same type first + // ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true + // and are the default + if (req.body.isDefault) { + settings.sonarr + .filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k) + .forEach((sonarrInstance) => { + sonarrInstance.isDefault = false; + }); + } + + settings.sonarr[sonarrIndex] = { + ...req.body, + id: Number(req.params.id), + } as SonarrSettings; + settings.save(); + + return res.status(200).json(settings.sonarr[sonarrIndex]); +}); + +sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => { + const settings = getSettings(); + + const sonarrIndex = settings.sonarr.findIndex( + (r) => r.id === Number(req.params.id) + ); + + if (sonarrIndex === -1) { + return res + .status(404) + .json({ status: '404', message: 'Settings instance not found' }); + } + + const removed = settings.sonarr.splice(sonarrIndex, 1); + settings.save(); + + return res.status(200).json(removed[0]); +}); + +export default sonarrRoutes; diff --git a/server/routes/user.ts b/server/routes/user.ts index b51b56cd..896278ef 100644 --- a/server/routes/user.ts +++ b/server/routes/user.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { getRepository } from 'typeorm'; +import { getRepository, Not } from 'typeorm'; import PlexTvAPI from '../api/plextv'; import { MediaRequest } from '../entity/MediaRequest'; import { User } from '../entity/User'; @@ -21,7 +21,7 @@ router.get('/', async (_req, res) => { router.post('/', async (req, res, next) => { try { - const settings = getSettings().notifications.agents.email; + const settings = getSettings(); const body = req.body; const userRepository = getRepository(User); @@ -29,7 +29,7 @@ router.post('/', async (req, res, next) => { const passedExplicitPassword = body.password && body.password.length > 0; const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 }); - if (!passedExplicitPassword && !settings.enabled) { + if (!passedExplicitPassword && !settings.notifications.agents.email) { throw new Error('Email notifications must be enabled'); } @@ -38,7 +38,7 @@ router.post('/', async (req, res, next) => { username: body.username ?? body.email, email: body.email, password: body.password, - permissions: Permission.REQUEST, + permissions: settings.main.defaultPermissions, plexToken: '', userType: UserType.LOCAL, }); @@ -70,6 +70,51 @@ router.get<{ id: string }>('/:id', async (req, res, next) => { } }); +const canMakePermissionsChange = (permissions: number, user?: User) => + // Only let the owner grant admin privileges + !(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1) || + // Only let users with the manage settings permission, grant the same permission + !( + hasPermission(Permission.MANAGE_SETTINGS, permissions) && + !hasPermission(Permission.MANAGE_SETTINGS, user?.permissions ?? 0) + ); + +router.put< + Record, + Partial[], + { ids: string[]; permissions: number } +>('/', async (req, res, next) => { + try { + const isOwner = req.user?.id === 1; + + if (!canMakePermissionsChange(req.body.permissions, req.user)) { + return next({ + status: 403, + message: 'You do not have permission to grant this level of access', + }); + } + + const userRepository = getRepository(User); + + const users = await userRepository.findByIds(req.body.ids, { + ...(!isOwner ? { id: Not(1) } : {}), + }); + + const updatedUsers = await Promise.all( + users.map(async (user) => { + return userRepository.save({ + ...user, + ...{ permissions: req.body.permissions }, + }); + }) + ); + + return res.status(200).json(updatedUsers); + } catch (e) { + next({ status: 500, message: e.message }); + } +}); + router.put<{ id: string }>('/:id', async (req, res, next) => { try { const userRepository = getRepository(User); @@ -86,29 +131,18 @@ router.put<{ id: string }>('/:id', async (req, res, next) => { }); } - // Only let the owner grant admin privileges - if ( - hasPermission(Permission.ADMIN, req.body.permissions) && - req.user?.id !== 1 - ) { + if (!canMakePermissionsChange(req.body.permissions, req.user)) { return next({ status: 403, message: 'You do not have permission to grant this level of access', }); } - // Only let users with the manage settings permission, grant the same permission - if ( - hasPermission(Permission.MANAGE_SETTINGS, req.body.permissions) && - !hasPermission(Permission.MANAGE_SETTINGS, req.user?.permissions ?? 0) - ) { - return next({ - status: 403, - message: 'You do not have permission to grant this level of access', - }); - } + Object.assign(user, { + username: req.body.username, + permissions: req.body.permissions, + }); - Object.assign(user, req.body); await userRepository.save(user); return res.status(200).json(user.filter()); @@ -183,20 +217,32 @@ router.post('/import-from-plex', async (req, res, next) => { const createdUsers: User[] = []; for (const rawUser of plexUsersResponse.MediaContainer.User) { const account = rawUser.$; + const user = await userRepository.findOne({ - where: { plexId: account.id }, + where: [{ plexId: account.id }, { email: account.email }], }); + if (user) { // Update the users avatar with their plex thumbnail (incase it changed) user.avatar = account.thumb; user.email = account.email; - user.username = account.username; + user.plexUsername = account.username; + + // in-case the user was previously a local account + if (user.userType === UserType.LOCAL) { + user.userType = UserType.PLEX; + user.plexId = parseInt(account.id); + + if (user.username === account.username) { + user.username = ''; + } + } await userRepository.save(user); } else { // Check to make sure it's a real account if (account.email && account.username) { const newUser = new User({ - username: account.username, + plexUsername: account.username, email: account.email, permissions: settings.main.defaultPermissions, plexId: parseInt(account.id), diff --git a/server/subscriber/MediaSubscriber.ts b/server/subscriber/MediaSubscriber.ts index a1289fb0..8414d9a9 100644 --- a/server/subscriber/MediaSubscriber.ts +++ b/server/subscriber/MediaSubscriber.ts @@ -21,7 +21,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { if (entity.mediaType === MediaType.MOVIE) { const requestRepository = getRepository(MediaRequest); const relatedRequests = await requestRepository.find({ - where: { media: entity }, + where: { media: entity, is4k: false }, }); if (relatedRequests.length > 0) { @@ -64,7 +64,7 @@ export class MediaSubscriber implements EntitySubscriberInterface { for (const changedSeasonNumber of changedSeasons) { const requests = await requestRepository.find({ - where: { media: entity }, + where: { media: entity, is4k: false }, }); const request = requests.find( (request) => diff --git a/server/types/plex-api.d.ts b/server/types/plex-api.d.ts index 9222faaf..2e6cdc16 100644 --- a/server/types/plex-api.d.ts +++ b/server/types/plex-api.d.ts @@ -5,6 +5,7 @@ declare module 'plex-api' { port: number; token?: string; https?: boolean; + timeout?: number; authenticator: { authenticate: ( _plexApi: PlexAPI, @@ -19,7 +20,7 @@ declare module 'plex-api' { }; requestOptions?: Record; }); - + // eslint-disable-next-line @typescript-eslint/no-explicit-any query: >(endpoint: string) => Promise; } } diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index b5dd5491..7aad6406 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -70,6 +70,9 @@ parts: cp -R $SNAPCRAFT_PART_BUILD/node_modules $SNAPCRAFT_PART_INSTALL/ # Remove .github and gitbook as it will fail snap lint rm -rf $SNAPCRAFT_PART_INSTALL/.github && rm $SNAPCRAFT_PART_INSTALL/.gitbook.yaml + stage-packages: + - on armhf: + - libatomic1 stage: [ .next, ./* ] prime: diff --git a/src/assets/available.svg b/src/assets/available.svg index 6612d4e2..87b9bdeb 100644 --- a/src/assets/available.svg +++ b/src/assets/available.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/src/assets/bolt.svg b/src/assets/bolt.svg index 20259b64..d83a0d8a 100644 --- a/src/assets/bolt.svg +++ b/src/assets/bolt.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/src/assets/download.svg b/src/assets/download.svg index 8f158978..4dd0492b 100644 --- a/src/assets/download.svg +++ b/src/assets/download.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/src/assets/ellipsis.svg b/src/assets/ellipsis.svg index 2c286cb0..1c5b9551 100644 --- a/src/assets/ellipsis.svg +++ b/src/assets/ellipsis.svg @@ -1,6 +1 @@ - - - - - - + \ No newline at end of file diff --git a/src/assets/extlogos/discord_white.svg b/src/assets/extlogos/discord_white.svg index 50ef8d29..bce41d99 100644 --- a/src/assets/extlogos/discord_white.svg +++ b/src/assets/extlogos/discord_white.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/src/assets/extlogos/pushover.svg b/src/assets/extlogos/pushover.svg index e3d7161f..7225c805 100644 --- a/src/assets/extlogos/pushover.svg +++ b/src/assets/extlogos/pushover.svg @@ -1,6 +1 @@ - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/extlogos/slack.svg b/src/assets/extlogos/slack.svg index dbcfb00b..f292c13c 100644 --- a/src/assets/extlogos/slack.svg +++ b/src/assets/extlogos/slack.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/src/assets/extlogos/telegram.svg b/src/assets/extlogos/telegram.svg index d10e5c88..f7cc4933 100644 --- a/src/assets/extlogos/telegram.svg +++ b/src/assets/extlogos/telegram.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/src/assets/requested.svg b/src/assets/requested.svg index cbb15a99..825678d0 100644 --- a/src/assets/requested.svg +++ b/src/assets/requested.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/src/assets/rt_aud_fresh.svg b/src/assets/rt_aud_fresh.svg index ecc9b5b0..7143281b 100644 --- a/src/assets/rt_aud_fresh.svg +++ b/src/assets/rt_aud_fresh.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/src/assets/rt_fresh.svg b/src/assets/rt_fresh.svg index ff792bcf..89c3e610 100644 --- a/src/assets/rt_fresh.svg +++ b/src/assets/rt_fresh.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/src/assets/rt_rotten.svg b/src/assets/rt_rotten.svg index 283ea5b6..e9c99d22 100644 --- a/src/assets/rt_rotten.svg +++ b/src/assets/rt_rotten.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/src/assets/services/imdb.svg b/src/assets/services/imdb.svg index b99f43a1..59602f7e 100644 --- a/src/assets/services/imdb.svg +++ b/src/assets/services/imdb.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/src/assets/services/plex.svg b/src/assets/services/plex.svg new file mode 100644 index 00000000..5debcdf3 --- /dev/null +++ b/src/assets/services/plex.svg @@ -0,0 +1 @@ +plex-logo \ No newline at end of file diff --git a/src/assets/services/rt.svg b/src/assets/services/rt.svg index 6783b157..a5560ffa 100644 --- a/src/assets/services/rt.svg +++ b/src/assets/services/rt.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/src/assets/services/tmdb.svg b/src/assets/services/tmdb.svg index 62a66055..84537a01 100644 --- a/src/assets/services/tmdb.svg +++ b/src/assets/services/tmdb.svg @@ -1 +1 @@ -Asset 4 \ No newline at end of file +Asset 4 \ No newline at end of file diff --git a/src/assets/spinner.svg b/src/assets/spinner.svg new file mode 100644 index 00000000..a5ade8a1 --- /dev/null +++ b/src/assets/spinner.svg @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/src/assets/unavailable.svg b/src/assets/unavailable.svg index 747f1c7b..d9474805 100644 --- a/src/assets/unavailable.svg +++ b/src/assets/unavailable.svg @@ -1,4 +1 @@ - - - - + \ No newline at end of file diff --git a/src/assets/useradd.svg b/src/assets/useradd.svg index 1fe26d46..1c6055ec 100644 --- a/src/assets/useradd.svg +++ b/src/assets/useradd.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/src/assets/xcircle.svg b/src/assets/xcircle.svg index 6fee8505..7a7b4533 100644 --- a/src/assets/xcircle.svg +++ b/src/assets/xcircle.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 38e3fb7b..d286b83b 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -20,7 +20,7 @@ import TitleCard from '../TitleCard'; import Transition from '../Transition'; const messages = defineMessages({ - overviewunavailable: 'Overview unavailable', + overviewunavailable: 'Overview unavailable.', overview: 'Overview', movies: 'Movies', numberofmovies: 'Number of Movies: {count}', diff --git a/src/components/Common/Accordion/index.tsx b/src/components/Common/Accordion/index.tsx new file mode 100644 index 00000000..67e883fe --- /dev/null +++ b/src/components/Common/Accordion/index.tsx @@ -0,0 +1,67 @@ +import * as React from 'react'; +import { useState } from 'react'; +import AnimateHeight from 'react-animate-height'; + +export interface AccordionProps { + children: (args: AccordionChildProps) => React.ReactElement | null; + /** If true, only one accordion item can be open at any time */ + single?: boolean; + /** If true, at least one accordion item will always be open */ + atLeastOne?: boolean; + initialOpenIndexes?: number[]; +} +export interface AccordionChildProps { + openIndexes: number[]; + handleClick(index: number): void; + AccordionContent: any; +} + +export const AccordionContent: React.FC<{ isOpen: boolean }> = ({ + isOpen, + children, +}) => { + return {children}; +}; + +const Accordion: React.FC = ({ + single, + atLeastOne, + initialOpenIndexes, + children, +}) => { + const initialState = initialOpenIndexes || (atLeastOne && [0]) || []; + const [openIndexes, setOpenIndexes] = useState(initialState); + + const close = (index: number) => { + const openCount = openIndexes.length; + const newListOfIndexes = + atLeastOne && openCount === 1 && openIndexes.includes(index) + ? openIndexes + : openIndexes.filter((i) => i !== index); + + setOpenIndexes(newListOfIndexes); + }; + + const open = (index: number) => { + const newListOfIndexes = single ? [index] : [...openIndexes, index]; + setOpenIndexes(newListOfIndexes); + }; + + const handleItemClick = (index: number) => { + const action = openIndexes.includes(index) ? 'closing' : 'opening'; + + if (action === 'closing') { + close(index); + } else { + open(index); + } + }; + + return children({ + openIndexes: openIndexes, + handleClick: handleItemClick, + AccordionContent, + }); +}; + +export default Accordion; diff --git a/src/components/Common/Alert/index.tsx b/src/components/Common/Alert/index.tsx index 0202c27d..15d63eae 100644 --- a/src/components/Common/Alert/index.tsx +++ b/src/components/Common/Alert/index.tsx @@ -2,7 +2,7 @@ import React from 'react'; interface AlertProps { title: string; - type?: 'warning' | 'info'; + type?: 'warning' | 'info' | 'error'; } const Alert: React.FC = ({ title, children, type }) => { @@ -51,6 +51,29 @@ const Alert: React.FC = ({ title, children, type }) => { ), }; break; + case 'error': + design = { + bgColor: 'bg-red-600', + titleColor: 'text-red-200', + textColor: 'text-red-300', + svg: ( + + + + ), + }; + break; } return ( diff --git a/src/components/Common/Badge/index.tsx b/src/components/Common/Badge/index.tsx index 62fd9072..9118415f 100644 --- a/src/components/Common/Badge/index.tsx +++ b/src/components/Common/Badge/index.tsx @@ -2,11 +2,16 @@ import React from 'react'; interface BadgeProps { badgeType?: 'default' | 'primary' | 'danger' | 'warning' | 'success'; + className?: string; } -const Badge: React.FC = ({ badgeType = 'default', children }) => { +const Badge: React.FC = ({ + badgeType = 'default', + className, + children, +}) => { const badgeStyle = [ - 'px-2 inline-flex text-xs leading-5 font-semibold rounded-full', + 'px-2 inline-flex text-xs leading-5 font-semibold rounded-full cursor-default', ]; switch (badgeType) { @@ -17,12 +22,16 @@ const Badge: React.FC = ({ badgeType = 'default', children }) => { badgeStyle.push('bg-yellow-500 text-yellow-100'); break; case 'success': - badgeStyle.push('bg-green-400 text-green-100'); + badgeStyle.push('bg-green-500 text-green-100'); break; default: badgeStyle.push('bg-indigo-500 text-indigo-100'); } + if (className) { + badgeStyle.push(className); + } + return {children}; }; diff --git a/src/components/Common/ButtonWithDropdown/index.tsx b/src/components/Common/ButtonWithDropdown/index.tsx index 422436c3..ecca1e15 100644 --- a/src/components/Common/ButtonWithDropdown/index.tsx +++ b/src/components/Common/ButtonWithDropdown/index.tsx @@ -9,22 +9,41 @@ import useClickOutside from '../../../hooks/useClickOutside'; import Transition from '../../Transition'; import { withProperties } from '../../../utils/typeHelpers'; -const DropdownItem: React.FC> = ({ +interface DropdownItemProps extends AnchorHTMLAttributes { + buttonType?: 'primary' | 'ghost'; +} + +const DropdownItem: React.FC = ({ children, + buttonType = 'primary', ...props -}) => ( - - {children} - -); +}) => { + let styleClass = ''; + + switch (buttonType) { + case 'ghost': + styleClass = + 'text-white bg-gray-700 hover:bg-gray-600 hover:text-white focus:border-gray-500 focus:text-white'; + break; + default: + styleClass = + 'text-white bg-indigo-600 hover:bg-indigo-500 hover:text-white focus:border-indigo-700 focus:text-white'; + } + return ( + + {children} + + ); +}; interface ButtonWithDropdownProps extends ButtonHTMLAttributes { text: ReactNode; dropdownIcon?: ReactNode; + buttonType?: 'primary' | 'ghost'; } const ButtonWithDropdown: React.FC = ({ @@ -32,29 +51,52 @@ const ButtonWithDropdown: React.FC = ({ children, dropdownIcon, className, + buttonType = 'primary', ...props }) => { const [isOpen, setIsOpen] = useState(false); const buttonRef = useRef(null); useClickOutside(buttonRef, () => setIsOpen(false)); + const styleClasses = { + mainButtonClasses: '', + dropdownSideButtonClasses: '', + dropdownClasses: '', + }; + + switch (buttonType) { + case 'ghost': + styleClasses.mainButtonClasses = + 'text-white bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'; + styleClasses.dropdownSideButtonClasses = + 'bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100'; + styleClasses.dropdownClasses = 'bg-gray-700'; + break; + default: + styleClasses.mainButtonClasses = + 'text-white bg-indigo-600 hover:text-white hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue'; + styleClasses.dropdownSideButtonClasses = + 'bg-indigo-700 border border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue'; + styleClasses.dropdownClasses = 'bg-indigo-600'; + } + return ( - + {children && ( + ); +}; + +export default ConfirmButton; diff --git a/src/components/Common/ListView/index.tsx b/src/components/Common/ListView/index.tsx index 3bba5214..fd9b6c79 100644 --- a/src/components/Common/ListView/index.tsx +++ b/src/components/Common/ListView/index.tsx @@ -7,6 +7,11 @@ import { import TitleCard from '../../TitleCard'; import useVerticalScroll from '../../../hooks/useVerticalScroll'; import PersonCard from '../../PersonCard'; +import { defineMessages, useIntl } from 'react-intl'; + +const messages = defineMessages({ + noresults: 'No results.', +}); interface ListViewProps { items?: (TvResult | MovieResult | PersonResult)[]; @@ -23,12 +28,13 @@ const ListView: React.FC = ({ onScrollBottom, isReachingEnd, }) => { + const intl = useIntl(); useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd); return ( <> {isEmpty && (
- No Results + {intl.formatMessage(messages.noresults)}
)} + {(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && ( + + )} {data?.mediaInfo && (
- +
{intl.formatMessage(messages.manageModalClearMediaWarning)}
@@ -165,7 +300,11 @@ const MovieDetails: React.FC = ({ movie }) => {
@@ -174,11 +313,28 @@ const MovieDetails: React.FC = ({ movie }) => {
{data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && ( - + 0} + plexUrl={data.mediaInfo?.plexUrl} + plexUrl4k={data.mediaInfo?.plexUrl4k} + /> )} - + 0} + plexUrl={data.mediaInfo?.plexUrl} + plexUrl4k={ + data.mediaInfo?.plexUrl4k && + (hasPermission(Permission.REQUEST_4K) || + hasPermission(Permission.REQUEST_4K_MOVIE)) + ? data.mediaInfo.plexUrl4k + : undefined + } + />

@@ -199,37 +355,86 @@ const MovieDetails: React.FC = ({ movie }) => {

- {trailerUrl && ( - + + + + + + {data.mediaInfo?.plexUrl + ? intl.formatMessage(messages.playonplex) + : data.mediaInfo?.plexUrl4k && + (hasPermission(Permission.REQUEST_4K) || + hasPermission(Permission.REQUEST_4K_MOVIE)) + ? intl.formatMessage(messages.playonplex) + : intl.formatMessage(messages.watchtrailer)} + + + } + onClick={() => { + if (data.mediaInfo?.plexUrl) { + window.open(data.mediaInfo?.plexUrl, '_blank'); + } else if (data.mediaInfo?.plexUrl4k) { + window.open(data.mediaInfo?.plexUrl4k, '_blank'); + } else if (trailerUrl) { + window.open(trailerUrl, '_blank'); + } + }} > - - + {data.mediaInfo?.plexUrl || + (data.mediaInfo?.plexUrl4k && + (hasPermission(Permission.REQUEST_4K) || + hasPermission(Permission.REQUEST_4K_MOVIE))) ? ( + <> + {data.mediaInfo?.plexUrl && + data.mediaInfo?.plexUrl4k && + (hasPermission(Permission.REQUEST_4K) || + hasPermission(Permission.REQUEST_4K_MOVIE)) && ( + { + window.open(data.mediaInfo?.plexUrl4k, '_blank'); + }} + buttonType="ghost" + > + {intl.formatMessage(messages.play4konplex)} + + )} + {(data.mediaInfo?.plexUrl || data.mediaInfo?.plexUrl4k) && + trailerUrl && ( + { + window.open(trailerUrl, '_blank'); + }} + buttonType="ghost" + > + {intl.formatMessage(messages.watchtrailer)} + + )} + + ) : null} + )}
= ({ movie }) => { tmdbId={data.id} imdbId={data.externalIds.imdbId} rtUrl={ratingData?.url} + plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k} />
diff --git a/src/components/PermissionEdit/index.tsx b/src/components/PermissionEdit/index.tsx new file mode 100644 index 00000000..88f27810 --- /dev/null +++ b/src/components/PermissionEdit/index.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import PermissionOption, { PermissionItem } from '../PermissionOption'; +import { Permission, User } from '../../hooks/useUser'; +import { useIntl, defineMessages } from 'react-intl'; + +export const messages = defineMessages({ + admin: 'Admin', + adminDescription: + 'Full administrator access. Bypasses all permission checks.', + users: 'Manage Users', + usersDescription: + 'Grants permission to manage Overseerr users. Users with this permission cannot modify users with Administrator privilege, or grant it.', + settings: 'Manage Settings', + settingsDescription: + 'Grants permission to modify all Overseerr settings. A user must have this permission to grant it to others.', + managerequests: 'Manage Requests', + managerequestsDescription: + 'Grants permission to manage Overseerr requests. This includes approving and denying requests.', + request: 'Request', + requestDescription: 'Grants permission to request movies and series.', + vote: 'Vote', + voteDescription: + 'Grants permission to vote on requests (voting not yet implemented)', + autoapprove: 'Auto Approve', + autoapproveDescription: + 'Grants auto approval for any requests made by this user.', + autoapproveMovies: 'Auto Approve Movies', + autoapproveMoviesDescription: + 'Grants auto approve for movie requests made by this user.', + autoapproveSeries: 'Auto Approve Series', + autoapproveSeriesDescription: + 'Grants auto approve for series requests made by this user.', + request4k: 'Request 4K', + request4kDescription: 'Grants permission to request 4K movies and series.', + request4kMovies: 'Request 4K Movies', + request4kMoviesDescription: 'Grants permission to request 4K movies.', + request4kTv: 'Request 4K Series', + request4kTvDescription: 'Grants permission to request 4K Series.', + advancedrequest: 'Advanced Requests', + advancedrequestDescription: + 'Grants permission to use advanced request options. (Ex. Changing servers/profiles/paths)', +}); + +interface PermissionEditProps { + currentPermission: number; + user?: User; + onUpdate: (newPermissions: number) => void; +} + +export const PermissionEdit: React.FC = ({ + currentPermission, + onUpdate, + user, +}) => { + const intl = useIntl(); + + const permissionList: PermissionItem[] = [ + { + id: 'admin', + name: intl.formatMessage(messages.admin), + description: intl.formatMessage(messages.adminDescription), + permission: Permission.ADMIN, + }, + { + id: 'settings', + name: intl.formatMessage(messages.settings), + description: intl.formatMessage(messages.settingsDescription), + permission: Permission.MANAGE_SETTINGS, + }, + { + id: 'users', + name: intl.formatMessage(messages.users), + description: intl.formatMessage(messages.usersDescription), + permission: Permission.MANAGE_USERS, + }, + { + id: 'managerequest', + name: intl.formatMessage(messages.managerequests), + description: intl.formatMessage(messages.managerequestsDescription), + permission: Permission.MANAGE_REQUESTS, + children: [ + { + id: 'advancedrequest', + name: intl.formatMessage(messages.advancedrequest), + description: intl.formatMessage(messages.advancedrequestDescription), + permission: Permission.REQUEST_ADVANCED, + }, + ], + }, + { + id: 'request', + name: intl.formatMessage(messages.request), + description: intl.formatMessage(messages.requestDescription), + permission: Permission.REQUEST, + }, + { + id: 'request4k', + name: intl.formatMessage(messages.request4k), + description: intl.formatMessage(messages.request4kDescription), + permission: Permission.REQUEST_4K, + children: [ + { + id: 'request4k-movies', + name: intl.formatMessage(messages.request4kMovies), + description: intl.formatMessage(messages.request4kMoviesDescription), + permission: Permission.REQUEST_4K_MOVIE, + }, + { + id: 'request4k-tv', + name: intl.formatMessage(messages.request4kTv), + description: intl.formatMessage(messages.request4kTvDescription), + permission: Permission.REQUEST_4K_TV, + }, + ], + }, + { + id: 'autoapprove', + name: intl.formatMessage(messages.autoapprove), + description: intl.formatMessage(messages.autoapproveDescription), + permission: Permission.AUTO_APPROVE, + children: [ + { + id: 'autoapprovemovies', + name: intl.formatMessage(messages.autoapproveMovies), + description: intl.formatMessage( + messages.autoapproveMoviesDescription + ), + permission: Permission.AUTO_APPROVE_MOVIE, + }, + { + id: 'autoapprovetv', + name: intl.formatMessage(messages.autoapproveSeries), + description: intl.formatMessage( + messages.autoapproveSeriesDescription + ), + permission: Permission.AUTO_APPROVE_TV, + }, + ], + }, + ]; + + return ( + <> + {permissionList.map((permissionItem) => ( + onUpdate(newPermission)} + /> + ))} + + ); +}; + +export default PermissionEdit; diff --git a/src/components/PlexLoginButton/index.tsx b/src/components/PlexLoginButton/index.tsx index 3c58e233..c97ee0c4 100644 --- a/src/components/PlexLoginButton/index.tsx +++ b/src/components/PlexLoginButton/index.tsx @@ -3,9 +3,9 @@ import PlexOAuth from '../../utils/plex'; import { defineMessages, useIntl } from 'react-intl'; const messages = defineMessages({ - loginwithplex: 'Login with Plex', - loading: 'Loading...', - loggingin: 'Logging in...', + signinwithplex: 'Sign In', + loading: 'Loading…', + signingin: 'Signing in…', }); const plexOAuth = new PlexOAuth(); @@ -51,8 +51,8 @@ const PlexLoginButton: React.FC = ({ {loading ? intl.formatMessage(messages.loading) : isProcessing - ? intl.formatMessage(messages.loggingin) - : intl.formatMessage(messages.loginwithplex)} + ? intl.formatMessage(messages.signingin) + : intl.formatMessage(messages.signinwithplex)} ); diff --git a/src/components/RequestBlock/index.tsx b/src/components/RequestBlock/index.tsx index e154f7e5..aa925aef 100644 --- a/src/components/RequestBlock/index.tsx +++ b/src/components/RequestBlock/index.tsx @@ -82,7 +82,7 @@ const RequestBlock: React.FC = ({ request, onUpdate }) => { /> - {request.requestedBy.username} + {request.requestedBy.displayName}
{request.modifiedBy && ( @@ -101,7 +101,7 @@ const RequestBlock: React.FC = ({ request, onUpdate }) => { /> - {request.modifiedBy?.username} + {request.modifiedBy?.displayName}
)} diff --git a/src/components/RequestButton/index.tsx b/src/components/RequestButton/index.tsx index 9c0a33d8..0f3ad6da 100644 --- a/src/components/RequestButton/index.tsx +++ b/src/components/RequestButton/index.tsx @@ -1,5 +1,5 @@ import axios from 'axios'; -import React, { useContext, useState } from 'react'; +import React, { useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; import { MediaRequestStatus, @@ -7,7 +7,7 @@ import { } from '../../../server/constants/media'; import Media from '../../../server/entity/Media'; import { MediaRequest } from '../../../server/entity/MediaRequest'; -import { SettingsContext } from '../../context/SettingsContext'; +import useSettings from '../../hooks/useSettings'; import { Permission, useUser } from '../../hooks/useUser'; import ButtonWithDropdown from '../Common/ButtonWithDropdown'; import RequestModal from '../RequestModal'; @@ -58,7 +58,7 @@ const RequestButton: React.FC = ({ is4kShowComplete = false, }) => { const intl = useIntl(); - const settings = useContext(SettingsContext); + const settings = useSettings(); const { hasPermission } = useUser(); const [showRequestModal, setShowRequestModal] = useState(false); const [showRequest4kModal, setShowRequest4kModal] = useState(false); diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index bd425f69..42255c49 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -108,7 +108,7 @@ const RequestCard: React.FC = ({ request }) => {
{intl.formatMessage(messages.requestedby, { - username: requestData.requestedBy.username, + username: requestData.requestedBy.displayName, })}
{requestData.media.status && ( @@ -120,6 +120,13 @@ const RequestCard: React.FC = ({ request }) => { : requestData.media.status } is4k={requestData.is4k} + inProgress={ + ( + requestData.media[ + requestData.is4k ? 'downloadStatus4k' : 'downloadStatus' + ] ?? [] + ).length > 0 + } /> )} @@ -205,7 +212,11 @@ const RequestCard: React.FC = ({ request }) => { } > diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index e4b98a0f..807555ef 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -30,7 +30,7 @@ const messages = defineMessages({ requestedby: 'Requested by {username}', seasons: 'Seasons', notavailable: 'N/A', - failedretry: 'Something went wrong retrying the request', + failedretry: 'Something went wrong while retrying the request.', }); const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { @@ -141,7 +141,11 @@ const RequestItem: React.FC = ({ > @@ -161,7 +165,7 @@ const RequestItem: React.FC = ({
{intl.formatMessage(messages.requestedby, { - username: requestData.requestedBy.username, + username: requestData.requestedBy.displayName, })}
{requestData.seasons.length > 0 && ( @@ -188,7 +192,16 @@ const RequestItem: React.FC = ({ : intl.formatMessage(globalMessages.failed)} ) : ( - + 0 + } + /> )} @@ -202,7 +215,8 @@ const RequestItem: React.FC = ({
{requestData.modifiedBy ? ( - {requestData.modifiedBy.username} ( + {requestData.modifiedBy.displayName} + ( { @@ -78,41 +79,50 @@ const MovieRequestModal: React.FC = ({ const sendRequest = useCallback(async () => { setIsUpdating(true); - let overrideParams = {}; - if (requestOverrides) { - overrideParams = { - serverId: requestOverrides.server, - profileId: requestOverrides.profile, - rootFolder: requestOverrides.folder, - }; - } - const response = await axios.post('/api/v1/request', { - mediaId: data?.id, - mediaType: 'movie', - is4k, - ...overrideParams, - }); - if (response.data) { - if (onComplete) { - onComplete( - hasPermission(Permission.AUTO_APPROVE) || - hasPermission(Permission.AUTO_APPROVE_MOVIE) - ? MediaStatus.PROCESSING - : MediaStatus.PENDING + try { + let overrideParams = {}; + if (requestOverrides) { + overrideParams = { + serverId: requestOverrides.server, + profileId: requestOverrides.profile, + rootFolder: requestOverrides.folder, + }; + } + const response = await axios.post('/api/v1/request', { + mediaId: data?.id, + mediaType: 'movie', + is4k, + ...overrideParams, + }); + + if (response.data) { + if (onComplete) { + onComplete( + hasPermission(Permission.AUTO_APPROVE) || + hasPermission(Permission.AUTO_APPROVE_MOVIE) + ? MediaStatus.PROCESSING + : MediaStatus.PENDING + ); + } + addToast( + + {intl.formatMessage(messages.requestSuccess, { + title: data?.title, + strong: function strong(msg) { + return {msg}; + }, + })} + , + { appearance: 'success', autoDismiss: true } ); } - addToast( - - {intl.formatMessage(messages.requestSuccess, { - title: data?.title, - strong: function strong(msg) { - return {msg}; - }, - })} - , - { appearance: 'success', autoDismiss: true } - ); + } catch (e) { + addToast(intl.formatMessage(messages.requesterror), { + appearance: 'error', + autoDismiss: true, + }); + } finally { setIsUpdating(false); } }, [data, onComplete, addToast, requestOverrides]); @@ -123,25 +133,29 @@ const MovieRequestModal: React.FC = ({ const cancelRequest = async () => { setIsUpdating(true); - const response = await axios.delete( - `/api/v1/request/${activeRequest?.id}` - ); - if (response.status === 204) { - if (onComplete) { - onComplete(MediaStatus.UNKNOWN); - } - addToast( - - {intl.formatMessage(messages.requestCancel, { - title: data?.title, - strong: function strong(msg) { - return {msg}; - }, - })} - , - { appearance: 'success', autoDismiss: true } + try { + const response = await axios.delete( + `/api/v1/request/${activeRequest?.id}` ); + + if (response.status === 204) { + if (onComplete) { + onComplete(MediaStatus.UNKNOWN); + } + addToast( + + {intl.formatMessage(messages.requestCancel, { + title: data?.title, + strong: function strong(msg) { + return {msg}; + }, + })} + , + { appearance: 'success', autoDismiss: true } + ); + } + } catch (e) { setIsUpdating(false); } }; @@ -210,7 +224,7 @@ const MovieRequestModal: React.FC = ({ {intl.formatMessage( is4k ? messages.request4kfrom : messages.requestfrom, { - username: activeRequest.requestedBy.username, + username: activeRequest.requestedBy.displayName, } )} {hasPermission(Permission.REQUEST_ADVANCED) && ( diff --git a/src/components/RequestModal/SearchByNameModal/index.tsx b/src/components/RequestModal/SearchByNameModal/index.tsx new file mode 100644 index 00000000..8356d5fe --- /dev/null +++ b/src/components/RequestModal/SearchByNameModal/index.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import Alert from '../../Common/Alert'; +import Modal from '../../Common/Modal'; +import { SmallLoadingSpinner } from '../../Common/LoadingSpinner'; +import useSWR from 'swr'; +import { defineMessages, useIntl } from 'react-intl'; +import { SonarrSeries } from '../../../../server/api/sonarr'; + +const messages = defineMessages({ + next: 'Next', + notvdbid: 'Manual Match Required', + notvdbiddescription: + "We couldn't automatically match your request. Please select the correct match from the list below:", + nosummary: 'No summary for this title was found.', +}); + +interface SearchByNameModalProps { + setTvdbId: (id: number) => void; + tvdbId: number | undefined; + loading: boolean; + onCancel?: () => void; + closeModal: () => void; + modalTitle: string; + tmdbId: number; +} + +const SearchByNameModal: React.FC = ({ + setTvdbId, + tvdbId, + loading, + onCancel, + closeModal, + modalTitle, + tmdbId, +}) => { + const intl = useIntl(); + const { data, error } = useSWR( + `/api/v1/service/sonarr/lookup/${tmdbId}` + ); + + const handleClick = (tvdbId: number) => { + setTvdbId(tvdbId); + }; + + return ( + + + + } + > + + {intl.formatMessage(messages.notvdbiddescription)} + + {!data && !error && } +
+ {data?.slice(0, 6).map((item) => ( + + ))} +
+
+ ); +}; + +export default SearchByNameModal; diff --git a/src/components/RequestModal/TvRequestModal.tsx b/src/components/RequestModal/TvRequestModal.tsx index f16dbb8e..3a331d89 100644 --- a/src/components/RequestModal/TvRequestModal.tsx +++ b/src/components/RequestModal/TvRequestModal.tsx @@ -18,6 +18,7 @@ import globalMessages from '../../i18n/globalMessages'; import SeasonRequest from '../../../server/entity/SeasonRequest'; import Alert from '../Common/Alert'; import AdvancedRequester, { RequestOverrides } from './AdvancedRequester'; +import SearchByNameModal from './SearchByNameModal'; const messages = defineMessages({ requestadmin: 'Your request will be immediately approved.', @@ -26,7 +27,7 @@ const messages = defineMessages({ requestSuccess: '{title} successfully requested!', requesttitle: 'Request {title}', request4ktitle: 'Request {title} in 4K', - requesting: 'Requesting...', + requesting: 'Requesting…', requestseasons: 'Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}', selectseason: 'Select season(s)', @@ -36,10 +37,16 @@ const messages = defineMessages({ seasonnumber: 'Season {number}', extras: 'Extras', notrequested: 'Not Requested', - errorediting: 'Something went wrong editing the request.', + errorediting: 'Something went wrong while editing the request.', requestedited: 'Request edited.', requestcancelled: 'Request cancelled.', - autoapproval: 'Auto Approval', + autoapproval: 'Automatic Approval', + requesterror: 'Something went wrong while submitting the request.', + next: 'Next', + notvdbid: 'No TVDB ID was found for the item on TMDb.', + notvdbiddescription: + 'Either add the TVDB ID to TMDb and try again later, or select the correct match below:', + backbutton: 'Back', }); interface RequestModalProps extends React.HTMLAttributes { @@ -73,6 +80,12 @@ const TvRequestModal: React.FC = ({ ); const intl = useIntl(); const { hasPermission } = useUser(); + const [searchModal, setSearchModal] = useState<{ + show: boolean; + }>({ + show: true, + }); + const [tvdbId, setTvdbId] = useState(undefined); const updateRequest = async () => { if (!editRequest) { @@ -129,38 +142,47 @@ const TvRequestModal: React.FC = ({ if (onUpdating) { onUpdating(true); } - let overrideParams = {}; - if (requestOverrides) { - overrideParams = { - serverId: requestOverrides.server, - profileId: requestOverrides.profile, - rootFolder: requestOverrides.folder, - }; - } - const response = await axios.post('/api/v1/request', { - mediaId: data?.id, - tvdbId: data?.externalIds.tvdbId, - mediaType: 'tv', - is4k, - seasons: selectedSeasons, - ...overrideParams, - }); - if (response.data) { - if (onComplete) { - onComplete(response.data.media.status); + try { + let overrideParams = {}; + if (requestOverrides) { + overrideParams = { + serverId: requestOverrides.server, + profileId: requestOverrides.profile, + rootFolder: requestOverrides.folder, + }; } - addToast( - - {intl.formatMessage(messages.requestSuccess, { - title: data?.name, - strong: function strong(msg) { - return {msg}; - }, - })} - , - { appearance: 'success', autoDismiss: true } - ); + const response = await axios.post('/api/v1/request', { + mediaId: data?.id, + tvdbId: tvdbId ?? data?.externalIds.tvdbId, + mediaType: 'tv', + is4k, + seasons: selectedSeasons, + ...overrideParams, + }); + + if (response.data) { + if (onComplete) { + onComplete(response.data.media.status); + } + addToast( + + {intl.formatMessage(messages.requestSuccess, { + title: data?.name, + strong: function strong(msg) { + return {msg}; + }, + })} + , + { appearance: 'success', autoDismiss: true } + ); + } + } catch (e) { + addToast(intl.formatMessage(messages.requesterror), { + appearance: 'error', + autoDismiss: true, + }); + } finally { if (onUpdating) { onUpdating(false); } @@ -188,7 +210,8 @@ const TvRequestModal: React.FC = ({ (season) => (season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || season[is4k ? 'status4k' : 'status'] === - MediaStatus.PARTIALLY_AVAILABLE) && + MediaStatus.PARTIALLY_AVAILABLE || + season[is4k ? 'status4k' : 'status'] === MediaStatus.PROCESSING) && !requestedSeasons.includes(season.seasonNumber) ) .map((season) => season.seasonNumber); @@ -279,11 +302,24 @@ const TvRequestModal: React.FC = ({ return seasonRequest; }; - return ( + return !data?.externalIds.tvdbId && searchModal.show ? ( + setSearchModal({ show: false })} + loading={!data && !error} + onCancel={onCancel} + modalTitle={intl.formatMessage( + is4k ? messages.request4ktitle : messages.requesttitle, + { title: data?.name } + )} + tmdbId={tmdbId} + /> + ) : ( setSearchModal({ show: true }) : onCancel} onOk={() => (editRequest ? updateRequest() : sendRequest())} title={intl.formatMessage( is4k ? messages.request4ktitle : messages.requesttitle, @@ -302,6 +338,11 @@ const TvRequestModal: React.FC = ({ okButtonType={ editRequest && selectedSeasons.length === 0 ? 'danger' : `primary` } + cancelText={ + tvdbId + ? intl.formatMessage(messages.backbutton) + : intl.formatMessage(globalMessages.cancel) + } iconSvg={ = ({ {intl.formatMessage(globalMessages.pending)} )} - {!mediaSeason && + {((!mediaSeason && seasonRequest?.status === - MediaRequestStatus.APPROVED && ( - - {intl.formatMessage(globalMessages.requested)} - - )} + MediaRequestStatus.APPROVED) || + mediaSeason?.[is4k ? 'status4k' : 'status'] === + MediaStatus.PROCESSING) && ( + + {intl.formatMessage(globalMessages.requested)} + + )} {!mediaSeason && seasonRequest?.status === MediaRequestStatus.AVAILABLE && ( diff --git a/src/components/Settings/CopyButton.tsx b/src/components/Settings/CopyButton.tsx index 0ddf4dbf..8e7af265 100644 --- a/src/components/Settings/CopyButton.tsx +++ b/src/components/Settings/CopyButton.tsx @@ -4,7 +4,7 @@ import { useToasts } from 'react-toast-notifications'; import { defineMessages, useIntl } from 'react-intl'; const messages = defineMessages({ - copied: 'Copied API key to clipboard', + copied: 'Copied API key to clipboard.', }); const CopyButton: React.FC<{ textToCopy: string }> = ({ textToCopy }) => { diff --git a/src/components/Settings/Notifications/NotificationsDiscord.tsx b/src/components/Settings/Notifications/NotificationsDiscord.tsx index f811c937..694524c6 100644 --- a/src/components/Settings/Notifications/NotificationsDiscord.tsx +++ b/src/components/Settings/Notifications/NotificationsDiscord.tsx @@ -11,11 +11,11 @@ import NotificationTypeSelector from '../../NotificationTypeSelector'; const messages = defineMessages({ save: 'Save Changes', - saving: 'Saving...', - agentenabled: 'Agent Enabled', + saving: 'Saving…', + agentenabled: 'Enable Agent', webhookUrl: 'Webhook URL', validationWebhookUrlRequired: 'You must provide a webhook URL', - webhookUrlPlaceholder: 'Server Settings -> Integrations -> Webhooks', + webhookUrlPlaceholder: 'Server Settings → Integrations → Webhooks', discordsettingssaved: 'Discord notification settings saved!', discordsettingsfailed: 'Discord notification settings failed to save.', testsent: 'Test notification sent!', diff --git a/src/components/Settings/Notifications/NotificationsEmail.tsx b/src/components/Settings/Notifications/NotificationsEmail.tsx index 1676304f..9767eba0 100644 --- a/src/components/Settings/Notifications/NotificationsEmail.tsx +++ b/src/components/Settings/Notifications/NotificationsEmail.tsx @@ -11,12 +11,12 @@ import NotificationTypeSelector from '../../NotificationTypeSelector'; const messages = defineMessages({ save: 'Save Changes', - saving: 'Saving...', - validationFromRequired: 'You must provide an email sender address', + saving: 'Saving…', + validationFromRequired: 'You must provide a sender address', validationSmtpHostRequired: 'You must provide an SMTP host', validationSmtpPortRequired: 'You must provide an SMTP port', - agentenabled: 'Agent Enabled', - emailsender: 'Email Sender Address', + agentenabled: 'Enable Agent', + emailsender: 'Sender Address', smtpHost: 'SMTP Host', smtpPort: 'SMTP Port', enableSsl: 'Enable SSL', @@ -28,7 +28,7 @@ const messages = defineMessages({ testsent: 'Test notification sent!', allowselfsigned: 'Allow Self-Signed Certificates', ssldisabletip: - 'SSL should be disabled on standard TLS connections (Port 587)', + 'SSL should be disabled on standard TLS connections (port 587)', senderName: 'Sender Name', notificationtypes: 'Notification Types', }); diff --git a/src/components/Settings/Notifications/NotificationsPushover/index.tsx b/src/components/Settings/Notifications/NotificationsPushover/index.tsx index 9d2c8d86..61827b3e 100644 --- a/src/components/Settings/Notifications/NotificationsPushover/index.tsx +++ b/src/components/Settings/Notifications/NotificationsPushover/index.tsx @@ -12,8 +12,8 @@ import NotificationTypeSelector from '../../../NotificationTypeSelector'; const messages = defineMessages({ save: 'Save Changes', - saving: 'Saving...', - agentenabled: 'Agent Enabled', + saving: 'Saving…', + agentenabled: 'Enable Agent', accessToken: 'Access Token', userToken: 'User Token', validationAccessTokenRequired: 'You must provide an access token.', @@ -22,7 +22,7 @@ const messages = defineMessages({ pushoversettingsfailed: 'Pushover notification settings failed to save.', testsent: 'Test notification sent!', test: 'Test', - settinguppushover: 'Setting up Pushover Notifications', + settinguppushover: 'Setting Up Pushover Notifications', settinguppushoverDescription: 'To setup Pushover you need to register an application and get the access token.\ When setting up the application you can use one of the icons in the public folder on github.\ diff --git a/src/components/Settings/Notifications/NotificationsSlack/index.tsx b/src/components/Settings/Notifications/NotificationsSlack/index.tsx index 3b4a18a8..b62f5b81 100644 --- a/src/components/Settings/Notifications/NotificationsSlack/index.tsx +++ b/src/components/Settings/Notifications/NotificationsSlack/index.tsx @@ -12,8 +12,8 @@ import NotificationTypeSelector from '../../../NotificationTypeSelector'; const messages = defineMessages({ save: 'Save Changes', - saving: 'Saving...', - agentenabled: 'Agent Enabled', + saving: 'Saving…', + agentenabled: 'Enable Agent', webhookUrl: 'Webhook URL', validationWebhookUrlRequired: 'You must provide a webhook URL', webhookUrlPlaceholder: 'Webhook URL', @@ -21,7 +21,7 @@ const messages = defineMessages({ slacksettingsfailed: 'Slack notification settings failed to save.', testsent: 'Test notification sent!', test: 'Test', - settingupslack: 'Setting up Slack Notifications', + settingupslack: 'Setting Up Slack Notifications', settingupslackDescription: 'To use Slack notifications, you will need to create an Incoming Webhook integration and use the provided webhook URL below.', notificationtypes: 'Notification Types', diff --git a/src/components/Settings/Notifications/NotificationsTelegram.tsx b/src/components/Settings/Notifications/NotificationsTelegram.tsx index aa01a2e3..3f779ec2 100644 --- a/src/components/Settings/Notifications/NotificationsTelegram.tsx +++ b/src/components/Settings/Notifications/NotificationsTelegram.tsx @@ -12,20 +12,20 @@ import NotificationTypeSelector from '../../NotificationTypeSelector'; const messages = defineMessages({ save: 'Save Changes', - saving: 'Saving...', - agentenabled: 'Agent Enabled', + saving: 'Saving…', + agentenabled: 'Enable Agent', botAPI: 'Bot API', - chatId: 'Chat Id', + chatId: 'Chat ID', validationBotAPIRequired: 'You must provide a Bot API key.', - validationChatIdRequired: 'You must provide a Chat id.', + validationChatIdRequired: 'You must provide a Chat ID.', telegramsettingssaved: 'Telegram notification settings saved!', telegramsettingsfailed: 'Telegram notification settings failed to save.', testsent: 'Test notification sent!', test: 'Test', - settinguptelegram: 'Setting up Telegram Notifications', + settinguptelegram: 'Setting Up Telegram Notifications', settinguptelegramDescription: 'To setup Telegram you need to create a bot and get the bot API key.\ - Additionally, you need the chat id for the chat you want the bot to send notifications to.\ + Additionally, you need the chat ID for the chat you want the bot to send notifications to.\ You can do this by adding @get_id_bot to the chat or group chat.', notificationtypes: 'Notification Types', }); diff --git a/src/components/Settings/Notifications/NotificationsWebhook/index.tsx b/src/components/Settings/Notifications/NotificationsWebhook/index.tsx index 189e9359..3faf095e 100644 --- a/src/components/Settings/Notifications/NotificationsWebhook/index.tsx +++ b/src/components/Settings/Notifications/NotificationsWebhook/index.tsx @@ -33,8 +33,8 @@ const defaultPayload = { const messages = defineMessages({ save: 'Save Changes', - saving: 'Saving...', - agentenabled: 'Agent Enabled', + saving: 'Saving…', + agentenabled: 'Enable Agent', webhookUrl: 'Webhook URL', authheader: 'Authorization Header', validationWebhookUrlRequired: 'You must provide a webhook URL', diff --git a/src/components/Settings/RadarrModal/index.tsx b/src/components/Settings/RadarrModal/index.tsx index db8f2807..2acb4711 100644 --- a/src/components/Settings/RadarrModal/index.tsx +++ b/src/components/Settings/RadarrModal/index.tsx @@ -19,12 +19,12 @@ const messages = defineMessages({ validationProfileRequired: 'You must select a profile', validationMinimumAvailabilityRequired: 'You must select minimum availability', toastRadarrTestSuccess: 'Radarr connection established!', - toastRadarrTestFailure: 'Failed to connect to Radarr Server', - saving: 'Saving...', + toastRadarrTestFailure: 'Failed to connect to Radarr.', + saving: 'Saving…', save: 'Save Changes', add: 'Add Server', test: 'Test', - testing: 'Testing...', + testing: 'Testing…', defaultserver: 'Default Server', servername: 'Server Name', servernamePlaceholder: 'A Radarr Server', @@ -32,20 +32,24 @@ const messages = defineMessages({ port: 'Port', ssl: 'SSL', apiKey: 'API Key', - apiKeyPlaceholder: 'Your Radarr API Key', + apiKeyPlaceholder: 'Your Radarr API key', baseUrl: 'Base URL', baseUrlPlaceholder: 'Example: /radarr', + syncEnabled: 'Enable Sync', + externalUrl: 'External URL', + externalUrlPlaceholder: 'External URL pointing to your Radarr server', qualityprofile: 'Quality Profile', rootfolder: 'Root Folder', minimumAvailability: 'Minimum Availability', server4k: '4K Server', - selectQualityProfile: 'Select a Quality Profile', - selectRootFolder: 'Select a Root Folder', + selectQualityProfile: 'Select quality profile', + selectRootFolder: 'Select root folder', selectMinimumAvailability: 'Select minimum availability', loadingprofiles: 'Loading quality profiles…', - testFirstQualityProfiles: 'Test your connection to load quality profiles', + testFirstQualityProfiles: 'Test connection to load quality profiles', loadingrootfolders: 'Loading root folders…', - testFirstRootFolders: 'Test your connection to load root folders', + testFirstRootFolders: 'Test connection to load root folders', + preventSearch: 'Disable Auto-Search', }); interface TestResponse { @@ -188,6 +192,9 @@ const RadarrModal: React.FC = ({ minimumAvailability: radarr?.minimumAvailability ?? 'released', isDefault: radarr?.isDefault ?? false, is4k: radarr?.is4k ?? false, + externalUrl: radarr?.externalUrl, + syncEnabled: radarr?.syncEnabled, + preventSearch: radarr?.preventSearch, }} validationSchema={RadarrSettingsSchema} onSubmit={async (values) => { @@ -209,6 +216,9 @@ const RadarrModal: React.FC = ({ is4k: values.is4k, minimumAvailability: values.minimumAvailability, isDefault: values.isDefault, + externalUrl: values.externalUrl, + syncEnabled: values.syncEnabled, + preventSearch: values.preventSearch, }; if (!radarr) { await axios.post('/api/v1/settings/radarr', submission); @@ -290,6 +300,22 @@ const RadarrModal: React.FC = ({ />
+
+ +
+ +
+
-
+
+
+
+ +
+ {errors.externalUrl && touched.externalUrl && ( +
+ {errors.externalUrl} +
+ )} +
+
+
+
+
+
+
+ +
+
diff --git a/src/components/Settings/SettingsAbout/Releases/index.tsx b/src/components/Settings/SettingsAbout/Releases/index.tsx index 1e27c07d..a77d3ce0 100644 --- a/src/components/Settings/SettingsAbout/Releases/index.tsx +++ b/src/components/Settings/SettingsAbout/Releases/index.tsx @@ -12,7 +12,7 @@ import globalMessages from '../../../../i18n/globalMessages'; const messages = defineMessages({ releases: 'Releases', - releasedataMissing: 'Release data missing. Is GitHub down?', + releasedataMissing: 'Release data unavailable. Is GitHub down?', versionChangelog: 'Version Changelog', viewongithub: 'View on GitHub', latestversion: 'Latest Version', @@ -20,7 +20,7 @@ const messages = defineMessages({ viewchangelog: 'View Changelog', runningDevelop: 'You are running a develop version of Overseerr!', runningDevelopMessage: - 'The changes in your version will not be available below. Please look at the GitHub repository for latest updates.', + 'The changes in your version will not be available below. Please see the GitHub repository for latest updates.', }); const REPO_RELEASE_API = diff --git a/src/components/Settings/SettingsAbout/index.tsx b/src/components/Settings/SettingsAbout/index.tsx index 0ab91ea8..c1dfbdba 100644 --- a/src/components/Settings/SettingsAbout/index.tsx +++ b/src/components/Settings/SettingsAbout/index.tsx @@ -17,7 +17,7 @@ const messages = defineMessages({ clickheretojoindiscord: 'Click here to join our Discord server.', timezone: 'Timezone', supportoverseerr: 'Support Overseerr', - helppaycoffee: 'Help pay for coffee', + helppaycoffee: 'Help Pay for Coffee', documentation: 'Documentation', }); @@ -92,7 +92,7 @@ const SettingsAbout: React.FC = () => {
{ const intl = useIntl(); - const { data, error } = useSWR<{ name: string; nextExecutionTime: string }[]>( - '/api/v1/settings/jobs' - ); + const { addToast } = useToasts(); + const { data, error, revalidate } = useSWR('/api/v1/settings/jobs', { + refreshInterval: 5000, + }); if (!data && !error) { return ; } + const runJob = async (job: Job) => { + await axios.get(`/api/v1/settings/jobs/${job.id}/run`); + addToast( + intl.formatMessage(messages.jobstarted, { + jobname: job.name, + }), + { + appearance: 'success', + autoDismiss: true, + } + ); + revalidate(); + }; + + const cancelJob = async (job: Job) => { + await axios.get(`/api/v1/settings/jobs/${job.id}/cancel`); + addToast(intl.formatMessage(messages.jobcancelled, { jobname: job.name }), { + appearance: 'error', + autoDismiss: true, + }); + revalidate(); + }; + return ( {intl.formatMessage(messages.jobname)} + {intl.formatMessage(messages.jobtype)}{intl.formatMessage(messages.nextexecution)} - {data?.map((job, index) => ( - + {data?.map((job) => ( + -
{job.name}
+
+ {job.running && } + {job.name} +
+
+ + + {job.type} +
@@ -46,9 +98,15 @@ const SettingsJobs: React.FC = () => {
- + {job.running ? ( + + ) : ( + + )} ))} diff --git a/src/components/Settings/SettingsLogs/index.tsx b/src/components/Settings/SettingsLogs/index.tsx index 9c6d9cf7..bac8cd3b 100644 --- a/src/components/Settings/SettingsLogs/index.tsx +++ b/src/components/Settings/SettingsLogs/index.tsx @@ -5,10 +5,10 @@ import React from 'react'; const SettingsLogs: React.FC = () => { return ( <> -
- Logs page is still being built. For now, you can access your logs +
+ This page is still being built. For now, you can access your logs directly in stdout (container logs) or looking in{' '} - /app/config/logs/overseerr.logs + /app/config/logs/overseerr.log.
); diff --git a/src/components/Settings/SettingsMain.tsx b/src/components/Settings/SettingsMain.tsx index 41a75424..fd715101 100644 --- a/src/components/Settings/SettingsMain.tsx +++ b/src/components/Settings/SettingsMain.tsx @@ -9,22 +9,30 @@ import Button from '../Common/Button'; import { defineMessages, useIntl } from 'react-intl'; import { useUser, Permission } from '../../hooks/useUser'; import { useToasts } from 'react-toast-notifications'; -import { messages as permissionMessages } from '../UserEdit'; -import PermissionOption, { PermissionItem } from '../PermissionOption'; +import Badge from '../Common/Badge'; +import globalMessages from '../../i18n/globalMessages'; +import PermissionEdit from '../PermissionEdit'; const messages = defineMessages({ generalsettings: 'General Settings', generalsettingsDescription: - 'These are settings related to general Overseerr configuration.', + 'Configure global and default settings for Overseerr.', save: 'Save Changes', - saving: 'Saving...', + saving: 'Saving…', apikey: 'API Key', applicationurl: 'Application URL', - toastApiKeySuccess: 'New API Key generated!', - toastApiKeyFailure: 'Something went wrong generating a new API Key.', - toastSettingsSuccess: 'Settings saved.', - toastSettingsFailure: 'Something went wrong saving settings.', + toastApiKeySuccess: 'New API key generated!', + toastApiKeyFailure: 'Something went wrong while generating a new API key.', + toastSettingsSuccess: 'Settings successfully saved!', + toastSettingsFailure: 'Something went wrong while saving settings.', defaultPermissions: 'Default User Permissions', + hideAvailable: 'Hide Available Media', + csrfProtection: 'Enable CSRF Protection', + csrfProtectionTip: + 'Sets external API access to read-only (Overseerr must be reloaded for changes to take effect)', + trustProxy: 'Enable Proxy Support', + trustProxyTip: + 'Allows Overseerr to correctly register client IP addresses behind a proxy (Overseerr must be reloaded for changes to take effect)', }); const SettingsMain: React.FC = () => { @@ -56,101 +64,6 @@ const SettingsMain: React.FC = () => { return ; } - const permissionList: PermissionItem[] = [ - { - id: 'admin', - name: intl.formatMessage(permissionMessages.admin), - description: intl.formatMessage(permissionMessages.adminDescription), - permission: Permission.ADMIN, - }, - { - id: 'settings', - name: intl.formatMessage(permissionMessages.settings), - description: intl.formatMessage(permissionMessages.settingsDescription), - permission: Permission.MANAGE_SETTINGS, - }, - { - id: 'users', - name: intl.formatMessage(permissionMessages.users), - description: intl.formatMessage(permissionMessages.usersDescription), - permission: Permission.MANAGE_USERS, - }, - { - id: 'managerequest', - name: intl.formatMessage(permissionMessages.managerequests), - description: intl.formatMessage( - permissionMessages.managerequestsDescription - ), - permission: Permission.MANAGE_REQUESTS, - children: [ - { - id: 'advancedrequest', - name: intl.formatMessage(permissionMessages.advancedrequest), - description: intl.formatMessage( - permissionMessages.advancedrequestDescription - ), - permission: Permission.REQUEST_ADVANCED, - }, - ], - }, - { - id: 'request', - name: intl.formatMessage(permissionMessages.request), - description: intl.formatMessage(permissionMessages.requestDescription), - permission: Permission.REQUEST, - }, - { - id: 'request4k', - name: intl.formatMessage(permissionMessages.request4k), - description: intl.formatMessage(permissionMessages.request4kDescription), - permission: Permission.REQUEST_4K, - children: [ - { - id: 'request4k-movies', - name: intl.formatMessage(permissionMessages.request4kMovies), - description: intl.formatMessage( - permissionMessages.request4kMoviesDescription - ), - permission: Permission.REQUEST_4K_MOVIE, - }, - { - id: 'request4k-tv', - name: intl.formatMessage(permissionMessages.request4kTv), - description: intl.formatMessage( - permissionMessages.request4kTvDescription - ), - permission: Permission.REQUEST_4K_TV, - }, - ], - }, - { - id: 'autoapprove', - name: intl.formatMessage(permissionMessages.autoapprove), - description: intl.formatMessage( - permissionMessages.autoapproveDescription - ), - permission: Permission.AUTO_APPROVE, - children: [ - { - id: 'autoapprovemovies', - name: intl.formatMessage(permissionMessages.autoapproveMovies), - description: intl.formatMessage( - permissionMessages.autoapproveMoviesDescription - ), - permission: Permission.AUTO_APPROVE_MOVIE, - }, - { - id: 'autoapprovetv', - name: intl.formatMessage(permissionMessages.autoapproveSeries), - description: intl.formatMessage( - permissionMessages.autoapproveSeriesDescription - ), - permission: Permission.AUTO_APPROVE_TV, - }, - ], - }, - ]; - return ( <>
@@ -165,14 +78,20 @@ const SettingsMain: React.FC = () => { { try { await axios.post('/api/v1/settings/main', { applicationUrl: values.applicationUrl, + csrfProtection: values.csrfProtection, defaultPermissions: values.defaultPermissions, + hideAvailable: values.hideAvailable, + trustProxy: values.trustProxy, }); addToast(intl.formatMessage(messages.toastSettingsSuccess), { @@ -256,6 +175,82 @@ const SettingsMain: React.FC = () => {
+
+ +
+ { + setFieldValue('trustProxy', !values.trustProxy); + }} + className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" + /> +
+
+
+ +
+ { + setFieldValue('csrfProtection', !values.csrfProtection); + }} + className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" + /> +
+
+
+ +
+ { + setFieldValue('hideAvailable', !values.hideAvailable); + }} + className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" + /> +
+
@@ -269,19 +264,15 @@ const SettingsMain: React.FC = () => {
- {permissionList.map((permissionItem) => ( - - setFieldValue( - 'defaultPermissions', - newPermissions - ) - } - /> - ))} + + setFieldValue( + 'defaultPermissions', + newPermissions + ) + } + />
diff --git a/src/components/Settings/SettingsNotifications.tsx b/src/components/Settings/SettingsNotifications.tsx index 8815b14f..a065cf00 100644 --- a/src/components/Settings/SettingsNotifications.tsx +++ b/src/components/Settings/SettingsNotifications.tsx @@ -7,11 +7,27 @@ import SlackLogo from '../../assets/extlogos/slack.svg'; import TelegramLogo from '../../assets/extlogos/telegram.svg'; import PushoverLogo from '../../assets/extlogos/pushover.svg'; import Bolt from '../../assets/bolt.svg'; +import { Field, Form, Formik } from 'formik'; +import useSWR from 'swr'; +import Error from '../../pages/_error'; +import LoadingSpinner from '../Common/LoadingSpinner'; +import axios from 'axios'; +import { useToasts } from 'react-toast-notifications'; +import Button from '../Common/Button'; const messages = defineMessages({ + save: 'Save Changes', + saving: 'Saving…', notificationsettings: 'Notification Settings', notificationsettingsDescription: - 'Here you can pick and choose what types of notifications to send and through what types of services.', + 'Configure global notification settings. The options below will apply to all notification agents.', + notificationAgentsSettings: 'Notification Agents', + notificationAgentSettingsDescription: + 'Choose the types of notifications to send, and which notification agents to use.', + notificationsettingssaved: 'Notification settings saved!', + notificationsettingsfailed: 'Notification settings failed to save.', + enablenotifications: 'Enable Notifications', + autoapprovedrequests: 'Send Notifications for Auto-Approved Requests', }); interface SettingsRoute { @@ -106,6 +122,8 @@ const settingsRoutes: SettingsRoute[] = [ const SettingsNotifications: React.FC = ({ children }) => { const router = useRouter(); const intl = useIntl(); + const { addToast } = useToasts(); + const { data, error, revalidate } = useSWR('/api/v1/settings/notifications'); const activeLinkColor = 'bg-indigo-700'; @@ -134,6 +152,14 @@ const SettingsNotifications: React.FC = ({ children }) => { ); }; + if (!data && !error) { + return ; + } + + if (!data) { + return ; + } + return ( <>
@@ -144,6 +170,112 @@ const SettingsNotifications: React.FC = ({ children }) => { {intl.formatMessage(messages.notificationsettingsDescription)}

+
+ { + try { + await axios.post('/api/v1/settings/notifications', { + enabled: values.enabled, + autoapprovalEnabled: values.autoapprovalEnabled, + }); + addToast(intl.formatMessage(messages.notificationsettingssaved), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast( + intl.formatMessage(messages.notificationsettingsfailed), + { + appearance: 'error', + autoDismiss: true, + } + ); + } finally { + revalidate(); + } + }} + > + {({ isSubmitting, values, setFieldValue }) => { + return ( +
+
+ +
+ { + setFieldValue('enabled', !values.enabled); + }} + className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" + /> +
+
+
+ +
+ { + setFieldValue( + 'autoapprovalEnabled', + !values.autoapprovalEnabled + ); + }} + className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" + /> +
+
+
+
+ + + +
+
+ + ); + }} +
+
+
+

+ {intl.formatMessage(messages.notificationAgentsSettings)} +

+

+ {intl.formatMessage(messages.notificationAgentSettingsDescription)} +

+
{ - setSubmitError(null); + let toastId: string | null = null; try { + addToast( + intl.formatMessage(messages.toastPlexConnecting), + { + autoDismiss: false, + appearance: 'info', + }, + (id) => { + toastId = id; + } + ); await axios.post('/api/v1/settings/plex', { ip: values.hostname, port: Number(values.port), @@ -176,10 +313,25 @@ const SettingsPlex: React.FC = ({ onComplete }) => { } as PlexSettings); revalidate(); + setSubmitError(null); + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastPlexConnectingSuccess), { + autoDismiss: true, + appearance: 'success', + }); if (onComplete) { onComplete(); } } catch (e) { + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastPlexConnectingFailure), { + autoDismiss: true, + appearance: 'error', + }); setSubmitError(e.response.data.message); } }} @@ -190,22 +342,25 @@ const SettingsPlex: React.FC = ({ onComplete }) => { values, handleSubmit, setFieldValue, + setFieldTouched, isSubmitting, }) => { return (
- {submitError && ( -
- {submitError} -
- )}
@@ -225,71 +380,176 @@ const SettingsPlex: React.FC = ({ onComplete }) => {
-
- - {values.useSsl ? 'https://' : 'http://'} - - +
+ +
- {errors.hostname && touched.hostname && ( -
{errors.hostname}
- )}
-
- -
-
+
+
+
+ +
+
+ + {values.useSsl ? 'https://' : 'http://'} + + +
+ {errors.hostname && touched.hostname && ( +
+ {errors.hostname} +
+ )} +
+
+
+
+
+ +
+
+ +
+ {errors.port && touched.port && ( +
{errors.port}
+ )} +
+
+
+
+ +
{ + setFieldValue('useSsl', !values.useSsl); + }} + className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" />
- {errors.port && touched.port && ( -
{errors.port}
- )}
-
- -
- { - setFieldValue('useSsl', !values.useSsl); - }} - className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" - /> + {submitError && ( +
+ + {submitError} +
-
+ )}
diff --git a/src/components/Settings/SettingsServices.tsx b/src/components/Settings/SettingsServices.tsx index 88557e1f..a58cb5e7 100644 --- a/src/components/Settings/SettingsServices.tsx +++ b/src/components/Settings/SettingsServices.tsx @@ -18,10 +18,10 @@ import Alert from '../Common/Alert'; const messages = defineMessages({ radarrsettings: 'Radarr Settings', radarrSettingsDescription: - 'Configure your Radarr connection below. You can have multiple Radarr configurations but only two can be active as defaults at any time (one for standard HD and one for 4K). Administrators can override which server will be used when a new request is made.', + 'Configure your Radarr connection below. You can have multiple Radarr configurations but only two can be active as defaults at any time (one for standard HD and one for 4K). Administrators can override the server will be used when a new request is made.', sonarrsettings: 'Sonarr Settings', sonarrSettingsDescription: - 'Configure your Sonarr connection below. You can have multiple Sonarr configurations but only two can be active as defaults at any time (one for standard HD and one for 4K). Administrators can override which server will be used when a new request is made.', + 'Configure your Sonarr connection below. You can have multiple Sonarr configurations but only two can be active as defaults at any time (one for standard HD and one for 4K). Administrators can override the server will be used when a new request is made.', deleteserverconfirm: 'Are you sure you want to delete this server?', edit: 'Edit', delete: 'Delete', diff --git a/src/components/Settings/SonarrModal/index.tsx b/src/components/Settings/SonarrModal/index.tsx index 3181eb33..cc2cea6c 100644 --- a/src/components/Settings/SonarrModal/index.tsx +++ b/src/components/Settings/SonarrModal/index.tsx @@ -17,13 +17,13 @@ const messages = defineMessages({ validationApiKeyRequired: 'You must provide an API key', validationRootFolderRequired: 'You must select a root folder', validationProfileRequired: 'You must select a profile', - toastRadarrTestSuccess: 'Sonarr connection established!', - toastRadarrTestFailure: 'Failed to connect to Sonarr Server', - saving: 'Saving...', + toastSonarrTestSuccess: 'Sonarr connection established!', + toastSonarrTestFailure: 'Failed to connect to Sonarr.', + saving: 'Saving…', save: 'Save Changes', add: 'Add Server', test: 'Test', - testing: 'Testing...', + testing: 'Testing…', defaultserver: 'Default Server', servername: 'Server Name', servernamePlaceholder: 'A Sonarr Server', @@ -31,7 +31,7 @@ const messages = defineMessages({ port: 'Port', ssl: 'SSL', apiKey: 'API Key', - apiKeyPlaceholder: 'Your Sonarr API Key', + apiKeyPlaceholder: 'Your Sonarr API key', baseUrl: 'Base URL', baseUrlPlaceholder: 'Example: /sonarr', qualityprofile: 'Quality Profile', @@ -40,12 +40,16 @@ const messages = defineMessages({ animerootfolder: 'Anime Root Folder', seasonfolders: 'Season Folders', server4k: '4K Server', - selectQualityProfile: 'Select a Quality Profile', - selectRootFolder: 'Select a Root Folder', + selectQualityProfile: 'Select quality profile', + selectRootFolder: 'Select root folder', loadingprofiles: 'Loading quality profiles…', - testFirstQualityProfiles: 'Test your connection to load quality profiles', + testFirstQualityProfiles: 'Test connection to load quality profiles', loadingrootfolders: 'Loading root folders…', - testFirstRootFolders: 'Test your connection to load root folders', + testFirstRootFolders: 'Test connection to load root folders', + syncEnabled: 'Enable Sync', + externalUrl: 'External URL', + externalUrlPlaceholder: 'External URL pointing to your Sonarr server', + preventSearch: 'Disable Auto-Search', }); interface TestResponse { @@ -189,6 +193,9 @@ const SonarrModal: React.FC = ({ isDefault: sonarr?.isDefault ?? false, is4k: sonarr?.is4k ?? false, enableSeasonFolders: sonarr?.enableSeasonFolders ?? false, + externalUrl: sonarr?.externalUrl, + syncEnabled: sonarr?.syncEnabled ?? false, + preventSearch: sonarr?.preventSearch ?? false, }} validationSchema={SonarrSettingsSchema} onSubmit={async (values) => { @@ -218,6 +225,9 @@ const SonarrModal: React.FC = ({ is4k: values.is4k, isDefault: values.isDefault, enableSeasonFolders: values.enableSeasonFolders, + externalUrl: values.externalUrl, + syncEnabled: values.syncEnabled, + preventSearch: values.preventSearch, }; if (!sonarr) { await axios.post('/api/v1/settings/sonarr', submission); @@ -299,6 +309,22 @@ const SonarrModal: React.FC = ({ />
+
+ +
+ +
+
-
- -
- -
-
+
+ +
+
+ +
+ {errors.externalUrl && touched.externalUrl && ( +
+ {errors.externalUrl} +
+ )} +
+
+
+ +
+ +
+
+
+ +
+ +
+
); diff --git a/src/components/Setup/index.tsx b/src/components/Setup/index.tsx index 7491f791..8b79e54a 100644 --- a/src/components/Setup/index.tsx +++ b/src/components/Setup/index.tsx @@ -13,7 +13,7 @@ import LanguagePicker from '../Layout/LanguagePicker'; const messages = defineMessages({ finish: 'Finish Setup', - finishing: 'Finishing...', + finishing: 'Finishing…', continue: 'Continue', loginwithplex: 'Login with Plex', configureplex: 'Configure Plex', diff --git a/src/components/Slider/index.tsx b/src/components/Slider/index.tsx index 03923225..442ff095 100644 --- a/src/components/Slider/index.tsx +++ b/src/components/Slider/index.tsx @@ -11,7 +11,7 @@ import TitleCard from '../TitleCard'; import { defineMessages, FormattedMessage } from 'react-intl'; const messages = defineMessages({ - noresults: 'No Results', + noresults: 'No results.', }); interface SliderProps { diff --git a/src/components/StatusBadge/index.tsx b/src/components/StatusBadge/index.tsx index fcc2d0d6..818e7654 100644 --- a/src/components/StatusBadge/index.tsx +++ b/src/components/StatusBadge/index.tsx @@ -3,6 +3,7 @@ import { MediaStatus } from '../../../server/constants/media'; import Badge from '../Common/Badge'; import { defineMessages, useIntl } from 'react-intl'; import globalMessages from '../../i18n/globalMessages'; +import Spinner from '../../assets/spinner.svg'; const messages = defineMessages({ status4k: '4K {status}', @@ -11,14 +12,38 @@ const messages = defineMessages({ interface StatusBadgeProps { status?: MediaStatus; is4k?: boolean; + inProgress?: boolean; + plexUrl?: string; + plexUrl4k?: string; } -const StatusBadge: React.FC = ({ status, is4k }) => { +const StatusBadge: React.FC = ({ + status, + is4k = false, + inProgress = false, + plexUrl, + plexUrl4k, +}) => { const intl = useIntl(); if (is4k) { switch (status) { case MediaStatus.AVAILABLE: + if (plexUrl4k) { + return ( + + + {intl.formatMessage(messages.status4k, { + status: intl.formatMessage(globalMessages.available), + })} + + + ); + } + return ( {intl.formatMessage(messages.status4k, { @@ -27,6 +52,21 @@ const StatusBadge: React.FC = ({ status, is4k }) => { ); case MediaStatus.PARTIALLY_AVAILABLE: + if (plexUrl4k) { + return ( + + + {intl.formatMessage(messages.status4k, { + status: intl.formatMessage(globalMessages.partiallyavailable), + })} + + + ); + } + return ( {intl.formatMessage(messages.status4k, { @@ -37,9 +77,16 @@ const StatusBadge: React.FC = ({ status, is4k }) => { case MediaStatus.PROCESSING: return ( - {intl.formatMessage(messages.status4k, { - status: intl.formatMessage(globalMessages.requested), - })} +
+ + {intl.formatMessage(messages.status4k, { + status: inProgress + ? intl.formatMessage(globalMessages.processing) + : intl.formatMessage(globalMessages.requested), + })} + + {inProgress && } +
); case MediaStatus.PENDING: @@ -57,21 +104,68 @@ const StatusBadge: React.FC = ({ status, is4k }) => { switch (status) { case MediaStatus.AVAILABLE: + if (plexUrl) { + return ( + + +
+ {intl.formatMessage(globalMessages.available)} + {inProgress && } +
+
+
+ ); + } + return ( - {intl.formatMessage(globalMessages.available)} +
+ {intl.formatMessage(globalMessages.available)} + {inProgress && } +
); case MediaStatus.PARTIALLY_AVAILABLE: + if (plexUrl) { + return ( + + +
+ + {intl.formatMessage(globalMessages.partiallyavailable)} + + {inProgress && } +
+
+
+ ); + } + return ( - {intl.formatMessage(globalMessages.partiallyavailable)} +
+ {intl.formatMessage(globalMessages.partiallyavailable)} + {inProgress && } +
); case MediaStatus.PROCESSING: return ( - {intl.formatMessage(globalMessages.requested)} +
+ + {inProgress + ? intl.formatMessage(globalMessages.processing) + : intl.formatMessage(globalMessages.requested)} + + {inProgress && } +
); case MediaStatus.PENDING: diff --git a/src/components/TitleCard/index.tsx b/src/components/TitleCard/index.tsx index 437d8e43..b4d8ba54 100644 --- a/src/components/TitleCard/index.tsx +++ b/src/components/TitleCard/index.tsx @@ -9,6 +9,7 @@ import RequestModal from '../RequestModal'; import { defineMessages, useIntl } from 'react-intl'; import { useIsTouch } from '../../hooks/useIsTouch'; import globalMessages from '../../i18n/globalMessages'; +import Spinner from '../../assets/spinner.svg'; const messages = defineMessages({ movie: 'Movie', @@ -25,6 +26,7 @@ interface TitleCardProps { mediaType: MediaType; status?: MediaStatus; canExpand?: boolean; + inProgress?: boolean; } const TitleCard: React.FC = ({ @@ -35,6 +37,7 @@ const TitleCard: React.FC = ({ title, status, mediaType, + inProgress = false, canExpand = false, }) => { const isTouch = useIsTouch(); @@ -80,7 +83,9 @@ const TitleCard: React.FC = ({ showDetail ? 'scale-105' : '' }`} style={{ - backgroundImage: `url(//image.tmdb.org/t/p/w300_and_h450_face${image})`, + backgroundImage: image + ? `url(//image.tmdb.org/t/p/w300_and_h450_face${image})` + : `url('/images/overseerr_poster_not_found_logo_top.png')`, }} onMouseEnter={() => { if (!isTouch) { @@ -144,18 +149,22 @@ const TitleCard: React.FC = ({ )} {currentStatus === MediaStatus.PROCESSING && (
- - - + {inProgress ? ( + + ) : ( + + + + )}
)}
diff --git a/src/components/TvDetails/TvRecommendations.tsx b/src/components/TvDetails/TvRecommendations.tsx index c27e97b5..46b638f8 100644 --- a/src/components/TvDetails/TvRecommendations.tsx +++ b/src/components/TvDetails/TvRecommendations.tsx @@ -7,10 +7,12 @@ import { LanguageContext } from '../../context/LanguageContext'; import Header from '../Common/Header'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { TvDetails } from '../../../server/models/Tv'; +import { MediaStatus } from '../../../server/constants/media'; +import useSettings from '../../hooks/useSettings'; const messages = defineMessages({ recommendations: 'Recommendations', - recommendationssubtext: 'If you liked {title}, you might also like...', + recommendationssubtext: 'If you liked {title}, you might also like…', }); interface SearchResult { @@ -21,6 +23,7 @@ interface SearchResult { } const TvRecommendations: React.FC = () => { + const settings = useSettings(); const router = useRouter(); const intl = useIntl(); const { locale } = useContext(LanguageContext); @@ -55,7 +58,18 @@ const TvRecommendations: React.FC = () => { return
{error}
; } - const titles = data?.reduce((a, v) => [...a, ...v.results], [] as TvResult[]); + let titles = (data ?? []).reduce( + (a, v) => [...a, ...v.results], + [] as TvResult[] + ); + + if (settings.currentSettings.hideAvailable) { + titles = titles.filter( + (i) => + i.mediaInfo?.status !== MediaStatus.AVAILABLE && + i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE + ); + } const isEmpty = !isLoadingInitialData && titles?.length === 0; const isReachingEnd = diff --git a/src/components/TvDetails/TvSimilar.tsx b/src/components/TvDetails/TvSimilar.tsx index fa812262..c0c4c05d 100644 --- a/src/components/TvDetails/TvSimilar.tsx +++ b/src/components/TvDetails/TvSimilar.tsx @@ -7,6 +7,8 @@ import { LanguageContext } from '../../context/LanguageContext'; import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; import type { TvDetails } from '../../../server/models/Tv'; import Header from '../Common/Header'; +import { MediaStatus } from '../../../server/constants/media'; +import useSettings from '../../hooks/useSettings'; const messages = defineMessages({ similar: 'Similar Series', @@ -21,6 +23,7 @@ interface SearchResult { } const TvSimilar: React.FC = () => { + const settings = useSettings(); const router = useRouter(); const intl = useIntl(); const { locale } = useContext(LanguageContext); @@ -55,11 +58,19 @@ const TvSimilar: React.FC = () => { return
{error}
; } - const titles = data?.reduce( + let titles = (data ?? []).reduce( (a, v) => [...a, ...v.results], [] as MovieResult[] ); + if (settings.currentSettings.hideAvailable) { + titles = titles.filter( + (i) => + i.mediaInfo?.status !== MediaStatus.AVAILABLE && + i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE + ); + } + const isEmpty = !isLoadingInitialData && titles?.length === 0; const isReachingEnd = isEmpty || (data && data[data.length - 1]?.results.length < 20); diff --git a/src/components/TvDetails/index.tsx b/src/components/TvDetails/index.tsx index ff2cd728..c998e7b4 100644 --- a/src/components/TvDetails/index.tsx +++ b/src/components/TvDetails/index.tsx @@ -35,6 +35,9 @@ import { Crew } from '../../../server/models/common'; import StatusBadge from '../StatusBadge'; import RequestButton from '../RequestButton'; import MediaSlider from '../MediaSlider'; +import ConfirmButton from '../Common/ConfirmButton'; +import DownloadBlock from '../DownloadBlock'; +import ButtonWithDropdown from '../Common/ButtonWithDropdown'; const messages = defineMessages({ firstAirDate: 'First Air Date', @@ -50,19 +53,28 @@ const messages = defineMessages({ available: 'Available', unavailable: 'Unavailable', pending: 'Pending', - overviewunavailable: 'Overview unavailable', + overviewunavailable: 'Overview unavailable.', manageModalTitle: 'Manage Series', manageModalRequests: 'Requests', manageModalNoRequests: 'No Requests', manageModalClearMedia: 'Clear All Media Data', manageModalClearMediaWarning: - 'This will remove all media data including all requests for this item. This action is irreversible. If this item exists in your Plex library, the media information will be recreated next sync.', + 'This will irreversibly remove all data for this TV series, including any requests. If this item exists in your Plex library, the media information will be recreated during the next sync.', approve: 'Approve', decline: 'Decline', showtype: 'Show Type', anime: 'Anime', network: 'Network', viewfullcrew: 'View Full Crew', + areyousure: 'Are you sure?', + opensonarr: 'Open Series in Sonarr', + opensonarr4k: 'Open Series in 4K Sonarr', + downloadstatus: 'Download Status', + playonplex: 'Play on Plex', + play4konplex: 'Play 4K on Plex', + markavailable: 'Mark as Available', + mark4kavailable: 'Mark 4K as Available', + allseasonsmarkedavailable: '* All seasons will be marked as available.', }); interface TvDetailsProps { @@ -111,6 +123,15 @@ const TvDetails: React.FC = ({ tv }) => { } }; + const markAvailable = async (is4k = false) => { + await axios.get(`/api/v1/media/${data?.mediaInfo?.id}/available`, { + params: { + is4k, + }, + }); + revalidate(); + }; + const isComplete = data.seasons.filter((season) => season.seasonNumber !== 0).length <= ( @@ -154,6 +175,83 @@ const TvDetails: React.FC = ({ tv }) => { onClose={() => setShowManager(false)} subText={data.name} > + {((data?.mediaInfo?.downloadStatus ?? []).length > 0 || + (data?.mediaInfo?.downloadStatus4k ?? []).length > 0) && ( + <> +

+ {intl.formatMessage(messages.downloadstatus)} +

+
+
    + {data.mediaInfo?.downloadStatus?.map((status, index) => ( +
  • + +
  • + ))} +
+
+ + )} + {data?.mediaInfo && + (data.mediaInfo.status !== MediaStatus.AVAILABLE || + data.mediaInfo.status4k !== MediaStatus.AVAILABLE) && ( +
+
+ {data?.mediaInfo && + data?.mediaInfo.status !== MediaStatus.AVAILABLE && ( + + )} + {data?.mediaInfo && + data?.mediaInfo.status4k !== MediaStatus.AVAILABLE && ( + + )} +
+
+ {intl.formatMessage(messages.allseasonsmarkedavailable)} +
+
+ )}

{intl.formatMessage(messages.manageModalRequests)}

@@ -174,15 +272,60 @@ const TvDetails: React.FC = ({ tv }) => { )}
+ {(data?.mediaInfo?.serviceUrl || data?.mediaInfo?.serviceUrl4k) && ( +
+ {data?.mediaInfo?.serviceUrl && ( + + + + )} + {data?.mediaInfo?.serviceUrl4k && ( + + + + )} +
+ )} {data?.mediaInfo && (
- +
{intl.formatMessage(messages.manageModalClearMediaWarning)}
@@ -192,7 +335,11 @@ const TvDetails: React.FC = ({ tv }) => {
@@ -201,11 +348,28 @@ const TvDetails: React.FC = ({ tv }) => {
{data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && ( - + 0} + plexUrl={data.mediaInfo?.plexUrl} + plexUrl4k={data.mediaInfo?.plexUrl4k} + /> )} - + 0} + plexUrl={data.mediaInfo?.plexUrl} + plexUrl4k={ + data.mediaInfo?.plexUrl4k && + (hasPermission(Permission.REQUEST_4K) || + hasPermission(Permission.REQUEST_4K_TV)) + ? data.mediaInfo.plexUrl4k + : undefined + } + />

@@ -221,37 +385,86 @@ const TvDetails: React.FC = ({ tv }) => {

- {trailerUrl && ( - + + + + + + {data.mediaInfo?.plexUrl + ? intl.formatMessage(messages.playonplex) + : data.mediaInfo?.plexUrl4k && + (hasPermission(Permission.REQUEST_4K) || + hasPermission(Permission.REQUEST_4K_TV)) + ? intl.formatMessage(messages.play4konplex) + : intl.formatMessage(messages.watchtrailer)} + + + } + onClick={() => { + if (data.mediaInfo?.plexUrl) { + window.open(data.mediaInfo?.plexUrl, '_blank'); + } else if (data.mediaInfo?.plexUrl4k) { + window.open(data.mediaInfo?.plexUrl4k, '_blank'); + } else if (trailerUrl) { + window.open(trailerUrl, '_blank'); + } + }} > - - + {data.mediaInfo?.plexUrl || + (data.mediaInfo?.plexUrl4k && + (hasPermission(Permission.REQUEST_4K) || + hasPermission(Permission.REQUEST_4K_TV))) ? ( + <> + {data.mediaInfo?.plexUrl && + data.mediaInfo?.plexUrl4k && + (hasPermission(Permission.REQUEST_4K) || + hasPermission(Permission.REQUEST_4K_TV)) && ( + { + window.open(data.mediaInfo?.plexUrl4k, '_blank'); + }} + buttonType="ghost" + > + {intl.formatMessage(messages.play4konplex)} + + )} + {(data.mediaInfo?.plexUrl || data.mediaInfo?.plexUrl4k) && + trailerUrl && ( + { + window.open(trailerUrl, '_blank'); + }} + buttonType="ghost" + > + {intl.formatMessage(messages.watchtrailer)} + + )} + + ) : null} + )}
= ({ tv }) => { tmdbId={data.id} imdbId={data.externalIds.imdbId} rtUrl={ratingData?.url} + plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k} />
diff --git a/src/components/UserEdit/index.tsx b/src/components/UserEdit/index.tsx index d1ee8720..5c90eb76 100644 --- a/src/components/UserEdit/index.tsx +++ b/src/components/UserEdit/index.tsx @@ -1,66 +1,34 @@ import React, { useState, useEffect } from 'react'; import { useRouter } from 'next/router'; import LoadingSpinner from '../Common/LoadingSpinner'; -import { Permission, useUser } from '../../hooks/useUser'; +import { useUser } from '../../hooks/useUser'; import Button from '../Common/Button'; import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; import axios from 'axios'; import { useToasts } from 'react-toast-notifications'; import Header from '../Common/Header'; -import PermissionOption, { PermissionItem } from '../PermissionOption'; +import PermissionEdit from '../PermissionEdit'; +import { Field, Form, Formik } from 'formik'; +import * as Yup from 'yup'; +import { UserType } from '../../../server/constants/user'; export const messages = defineMessages({ edituser: 'Edit User', - username: 'Username', + plexUsername: 'Plex Username', + username: 'Display Name', avatar: 'Avatar', email: 'Email', permissions: 'Permissions', - admin: 'Admin', - adminDescription: - 'Full administrator access. Bypasses all permission checks.', - users: 'Manage Users', - usersDescription: - 'Grants permission to manage Overseerr users. Users with this permission cannot modify users with Administrator privilege, or grant it.', - settings: 'Manage Settings', - settingsDescription: - 'Grants permission to modify all Overseerr settings. A user must have this permission to grant it to others.', - managerequests: 'Manage Requests', - managerequestsDescription: - 'Grants permission to manage Overseerr requests. This includes approving and denying requests.', - request: 'Request', - requestDescription: 'Grants permission to request movies and series.', - vote: 'Vote', - voteDescription: - 'Grants permission to vote on requests (voting not yet implemented)', - autoapprove: 'Auto Approve', - autoapproveDescription: - 'Grants auto approval for any requests made by this user.', - autoapproveMovies: 'Auto Approve Movies', - autoapproveMoviesDescription: - 'Grants auto approve for movie requests made by this user.', - autoapproveSeries: 'Auto Approve Series', - autoapproveSeriesDescription: - 'Grants auto approve for series requests made by this user.', - request4k: 'Request 4K', - request4kDescription: 'Grants permission to request 4K movies and series.', - request4kMovies: 'Request 4K Movies', - request4kMoviesDescription: 'Grants permission to request 4K movies.', - request4kTv: 'Request 4K Series', - request4kTvDescription: 'Grants permission to request 4K Series.', - advancedrequest: 'Advanced Requests', - advancedrequestDescription: - 'Grants permission to use advanced request options. (Ex. Changing servers/profiles/paths)', save: 'Save', - saving: 'Saving...', + saving: 'Saving…', usersaved: 'User saved', - userfail: 'Something went wrong saving the user.', + userfail: 'Something went wrong while saving the user.', }); const UserEdit: React.FC = () => { const router = useRouter(); const intl = useIntl(); const { addToast } = useToasts(); - const [isUpdating, setIsUpdating] = useState(false); const { user: currentUser } = useUser(); const { user, error, revalidate } = useUser({ id: Number(router.query.userId), @@ -75,244 +43,186 @@ const UserEdit: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [user]); - const updateUser = async () => { - try { - setIsUpdating(true); - - await axios.put(`/api/v1/user/${user?.id}`, { - permissions: currentPermission, - email: user?.email, - }); - - addToast(intl.formatMessage(messages.usersaved), { - appearance: 'success', - autoDismiss: true, - }); - } catch (e) { - addToast(intl.formatMessage(messages.userfail), { - appearance: 'error', - autoDismiss: true, - }); - throw new Error(`Something went wrong saving the user: ${e.message}`); - } finally { - revalidate(); - setIsUpdating(false); - } - }; - if (!user && !error) { return ; } - const permissionList: PermissionItem[] = [ - { - id: 'admin', - name: intl.formatMessage(messages.admin), - description: intl.formatMessage(messages.adminDescription), - permission: Permission.ADMIN, - }, - { - id: 'settings', - name: intl.formatMessage(messages.settings), - description: intl.formatMessage(messages.settingsDescription), - permission: Permission.MANAGE_SETTINGS, - }, - { - id: 'users', - name: intl.formatMessage(messages.users), - description: intl.formatMessage(messages.usersDescription), - permission: Permission.MANAGE_USERS, - }, - { - id: 'managerequest', - name: intl.formatMessage(messages.managerequests), - description: intl.formatMessage(messages.managerequestsDescription), - permission: Permission.MANAGE_REQUESTS, - children: [ - { - id: 'advancedrequest', - name: intl.formatMessage(messages.advancedrequest), - description: intl.formatMessage(messages.advancedrequestDescription), - permission: Permission.REQUEST_ADVANCED, - }, - ], - }, - { - id: 'request', - name: intl.formatMessage(messages.request), - description: intl.formatMessage(messages.requestDescription), - permission: Permission.REQUEST, - }, - { - id: 'request4k', - name: intl.formatMessage(messages.request4k), - description: intl.formatMessage(messages.request4kDescription), - permission: Permission.REQUEST_4K, - children: [ - { - id: 'request4k-movies', - name: intl.formatMessage(messages.request4kMovies), - description: intl.formatMessage(messages.request4kMoviesDescription), - permission: Permission.REQUEST_4K_MOVIE, - }, - { - id: 'request4k-tv', - name: intl.formatMessage(messages.request4kTv), - description: intl.formatMessage(messages.request4kTvDescription), - permission: Permission.REQUEST_4K_TV, - }, - ], - }, - { - id: 'autoapprove', - name: intl.formatMessage(messages.autoapprove), - description: intl.formatMessage(messages.autoapproveDescription), - permission: Permission.AUTO_APPROVE, - children: [ - { - id: 'autoapprovemovies', - name: intl.formatMessage(messages.autoapproveMovies), - description: intl.formatMessage( - messages.autoapproveMoviesDescription - ), - permission: Permission.AUTO_APPROVE_MOVIE, - }, - { - id: 'autoapprovetv', - name: intl.formatMessage(messages.autoapproveSeries), - description: intl.formatMessage( - messages.autoapproveSeriesDescription - ), - permission: Permission.AUTO_APPROVE_TV, - }, - ], - }, - ]; + const UserEditSchema = Yup.object().shape({ + username: Yup.string(), + }); return ( - <> -
- -
-
-
-
-
- -
- + { + try { + await axios.put(`/api/v1/user/${user?.id}`, { + permissions: currentPermission, + email: user?.email, + username: values.username, + }); + addToast(intl.formatMessage(messages.usersaved), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast(intl.formatMessage(messages.userfail), { + appearance: 'error', + autoDismiss: true, + }); + throw new Error( + `Something went wrong while saving the user: ${e.message}` + ); + } finally { + revalidate(); + } + }} + > + {({ isSubmitting, handleSubmit }) => ( + +
+ +
+
+
+
+ {user?.userType === UserType.PLEX && ( +
+ +
+ +
+
+ )} +
+ +
+ +
+
+
+ +
+ +
+
-
-
- -
- -
-
-
-
- -
-
-
+ +
+
+ +
+
+ +
- -
- -
-
-
-
-
-
-
-
-
- +
+
+
+
+
+
+ +
+
+
+
+ + setCurrentPermission(newPermission) + } + /> +
+
-
-
- {permissionList.map((permissionItem) => ( - - setCurrentPermission(newPermission) - } - /> - ))} -
+
+
+
+ + +
-
-
- - - -
-
-
-
- + + )} + ); }; diff --git a/src/components/UserList/BulkEditModal.tsx b/src/components/UserList/BulkEditModal.tsx new file mode 100644 index 00000000..d3828082 --- /dev/null +++ b/src/components/UserList/BulkEditModal.tsx @@ -0,0 +1,115 @@ +import React, { useEffect, useState } from 'react'; +import PermissionEdit from '../PermissionEdit'; +import Modal from '../Common/Modal'; +import { User, useUser } from '../../hooks/useUser'; +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import axios from 'axios'; +import { useToasts } from 'react-toast-notifications'; +import { messages as userEditMessages } from '../UserEdit'; + +interface BulkEditProps { + selectedUserIds: number[]; + users?: User[]; + onCancel?: () => void; + onComplete?: (updatedUsers: User[]) => void; + onSaving?: (isSaving: boolean) => void; +} + +const messages = defineMessages({ + userssaved: 'Users saved', +}); + +const BulkEditModal: React.FC = ({ + selectedUserIds, + users, + onCancel, + onComplete, + onSaving, +}) => { + const { user: currentUser } = useUser(); + const intl = useIntl(); + const { addToast } = useToasts(); + const [currentPermission, setCurrentPermission] = useState(0); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + if (onSaving) { + onSaving(isSaving); + } + }, [isSaving, onSaving]); + + const updateUsers = async () => { + try { + setIsSaving(true); + const { data: updated } = await axios.put(`/api/v1/user`, { + ids: selectedUserIds, + permissions: currentPermission, + }); + if (onComplete) { + onComplete(updated); + } + addToast(intl.formatMessage(messages.userssaved), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast(intl.formatMessage(userEditMessages.userfail), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + setIsSaving(false); + } + }; + + useEffect(() => { + if (users) { + const selectedUsers = users.filter((u) => selectedUserIds.includes(u.id)); + const { permissions: allPermissionsEqual } = selectedUsers.reduce( + ({ permissions: aPerms }, { permissions: bPerms }) => { + return { + permissions: aPerms === bPerms ? aPerms : NaN, + }; + }, + { permissions: selectedUsers[0].permissions } + ); + if (allPermissionsEqual) { + setCurrentPermission(allPermissionsEqual); + } + } + }, [users, selectedUserIds]); + + return ( + { + updateUsers(); + }} + okDisabled={isSaving} + okText={intl.formatMessage(userEditMessages.save)} + onCancel={onCancel} + > +
+
+
+ +
+
+
+
+ setCurrentPermission(newPermission)} + /> +
+
+
+
+ ); +}; + +export default BulkEditModal; diff --git a/src/components/UserList/index.tsx b/src/components/UserList/index.tsx index 7b7d6af1..86e1b189 100644 --- a/src/components/UserList/index.tsx +++ b/src/components/UserList/index.tsx @@ -6,7 +6,7 @@ import Badge from '../Common/Badge'; import { FormattedDate, defineMessages, useIntl } from 'react-intl'; import Button from '../Common/Button'; import { hasPermission } from '../../../server/lib/permissions'; -import { Permission, UserType } from '../../hooks/useUser'; +import { Permission, UserType, useUser } from '../../hooks/useUser'; import { useRouter } from 'next/router'; import Header from '../Common/Header'; import Table from '../Common/Table'; @@ -19,13 +19,14 @@ import { Field, Form, Formik } from 'formik'; import * as Yup from 'yup'; import AddUserIcon from '../../assets/useradd.svg'; import Alert from '../Common/Alert'; +import BulkEditModal from './BulkEditModal'; const messages = defineMessages({ userlist: 'User List', - importfromplex: 'Import Users From Plex', - importfromplexerror: 'Something went wrong importing users from Plex', + importfromplex: 'Import Users from Plex', + importfromplexerror: 'Something went wrong while importing users from Plex.', importedfromplex: - '{userCount, plural, =0 {No new users} one {# new user} other {# new users}} imported from Plex', + '{userCount, plural, =0 {No new users} one {# new user} other {# new users}} imported from Plex.', username: 'Username', totalrequests: 'Total Requests', usertype: 'User Type', @@ -33,13 +34,14 @@ const messages = defineMessages({ created: 'Created', lastupdated: 'Last Updated', edit: 'Edit', + bulkedit: 'Bulk Edit', delete: 'Delete', admin: 'Admin', user: 'User', plexuser: 'Plex User', deleteuser: 'Delete User', userdeleted: 'User deleted', - userdeleteerror: 'Something went wrong deleting the user', + userdeleteerror: 'Something went wrong while deleting the user.', deleteconfirm: 'Are you sure you want to delete this user? All existing request data from this user will be removed.', localuser: 'Local User', @@ -47,16 +49,16 @@ const messages = defineMessages({ createuser: 'Create User', creating: 'Creating', create: 'Create', - validationemailrequired: 'Must enter a valid email address.', + validationemailrequired: 'Must enter a valid email address', validationpasswordminchars: - 'Password is too short - should be 8 chars minimum.', - usercreatedfailed: 'Something went wrong when trying to create the user', - usercreatedsuccess: 'Successfully created the user', + 'Password is too short; should be a minimum of 8 characters', + usercreatedfailed: 'Something went wrong while creating the user.', + usercreatedsuccess: 'User created successfully!', email: 'Email Address', password: 'Password', - passwordinfo: 'Password Info', + passwordinfo: 'Password Information', passwordinfodescription: - 'Email notification settings need to be enabled and setup in order to use the auto generated passwords', + 'Email notifications need to be configured and enabled in order to automatically generate passwords.', autogeneratepassword: 'Automatically generate password', }); @@ -78,6 +80,39 @@ const UserList: React.FC = () => { }>({ isOpen: false, }); + const [showBulkEditModal, setShowBulkEditModal] = useState(false); + const [selectedUsers, setSelectedUsers] = useState([]); + const { user: currentUser } = useUser(); + + const isUserPermsEditable = (userId: number) => + userId !== 1 && userId !== currentUser?.id; + const isAllUsersSelected = () => { + return ( + selectedUsers.length === + data?.filter((user) => user.id !== currentUser?.id).length + ); + }; + const isUserSelected = (userId: number) => selectedUsers.includes(userId); + const toggleAllUsers = () => { + if ( + data && + selectedUsers.length >= 0 && + selectedUsers.length < data?.length - 1 + ) { + setSelectedUsers( + data.filter((user) => isUserPermsEditable(user.id)).map((u) => u.id) + ); + } else { + setSelectedUsers([]); + } + }; + const toggleUser = (userId: number) => { + if (selectedUsers.includes(userId)) { + setSelectedUsers((users) => users.filter((u) => u !== userId)); + } else { + setSelectedUsers((users) => [...users, userId]); + } + }; const deleteUser = async () => { setDeleting(true); @@ -183,6 +218,7 @@ const UserList: React.FC = () => { {intl.formatMessage(messages.deleteconfirm)} + { }} + + + setShowBulkEditModal(false)} + onComplete={() => { + setShowBulkEditModal(false); + revalidate(); + }} + selectedUserIds={selectedUsers} + users={data} + /> + +
{intl.formatMessage(messages.userlist)}
@@ -333,21 +390,57 @@ const UserList: React.FC = () => {
+
+ + { + toggleAllUsers(); + }} + className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" + /> + {intl.formatMessage(messages.username)}{intl.formatMessage(messages.totalrequests)}{intl.formatMessage(messages.usertype)}{intl.formatMessage(messages.role)}{intl.formatMessage(messages.created)}{intl.formatMessage(messages.lastupdated)} - + + + {data?.map((user) => ( + + {isUserPermsEditable(user.id) && ( + { + toggleUser(user.id); + }} + className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox" + /> + )} +
@@ -359,7 +452,7 @@ const UserList: React.FC = () => {
- {user.username} + {user.displayName}
{user.email} diff --git a/src/context/LanguageContext.tsx b/src/context/LanguageContext.tsx index ef68931e..a4c95873 100644 --- a/src/context/LanguageContext.tsx +++ b/src/context/LanguageContext.tsx @@ -11,6 +11,7 @@ export type AvailableLocales = | 'es' | 'it' | 'pt-BR' + | 'pt-PT' | 'sr' | 'sv' | 'zh-Hant'; diff --git a/src/context/SettingsContext.tsx b/src/context/SettingsContext.tsx index 18355545..ef12affa 100644 --- a/src/context/SettingsContext.tsx +++ b/src/context/SettingsContext.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { PublicSettingsResponse } from '../../server/interfaces/api/settingsInterfaces'; import useSWR from 'swr'; -interface SettingsContextProps { +export interface SettingsContextProps { currentSettings: PublicSettingsResponse; } @@ -10,6 +10,7 @@ const defaultSettings = { initialized: false, movie4kEnabled: false, series4kEnabled: false, + hideAvailable: false, }; export const SettingsContext = React.createContext({ diff --git a/src/context/UserContext.tsx b/src/context/UserContext.tsx index 24809a18..ba1bc1a1 100644 --- a/src/context/UserContext.tsx +++ b/src/context/UserContext.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react'; -import { User, useUser } from '../hooks/useUser'; +import { useUser, User } from '../hooks/useUser'; import { useRouter } from 'next/dist/client/router'; interface UserContextProps { diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts new file mode 100644 index 00000000..0fb7d7e9 --- /dev/null +++ b/src/hooks/useSettings.ts @@ -0,0 +1,13 @@ +import { useContext } from 'react'; +import { + SettingsContext, + SettingsContextProps, +} from '../context/SettingsContext'; + +const useSettings = (): SettingsContextProps => { + const settings = useContext(SettingsContext); + + return settings; +}; + +export default useSettings; diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts index c2102a00..18d67bf0 100644 --- a/src/hooks/useUser.ts +++ b/src/hooks/useUser.ts @@ -2,17 +2,19 @@ import useSwr from 'swr'; import { hasPermission, Permission } from '../../server/lib/permissions'; import { UserType } from '../../server/constants/user'; +export { Permission, UserType }; + export interface User { id: number; - username: string; + plexUsername?: string; + username?: string; + displayName: string; email: string; avatar: string; permissions: number; userType: number; } -export { Permission, UserType }; - interface UserHookResponse { user?: User; loading: boolean; diff --git a/src/i18n/globalMessages.ts b/src/i18n/globalMessages.ts index 6daeb698..810ba0ca 100644 --- a/src/i18n/globalMessages.ts +++ b/src/i18n/globalMessages.ts @@ -21,6 +21,7 @@ const globalMessages = defineMessages({ deleting: 'Deleting…', close: 'Close', edit: 'Edit', + experimental: 'Experimental', }); export default globalMessages; diff --git a/src/i18n/locale/de.json b/src/i18n/locale/de.json index 9a7d9e60..7111b8e9 100644 --- a/src/i18n/locale/de.json +++ b/src/i18n/locale/de.json @@ -16,7 +16,7 @@ "components.Layout.Sidebar.settings": "Einstellungen", "components.Layout.Sidebar.users": "Benutzer", "components.Layout.UserDropdown.signout": "Abmelden", - "components.Layout.alphawarning": "Dies ist eine ALPHA-Software. Fast alles kann kaputt und/oder instabil sein. Bitte melde Probleme auf der GitHub-Seite!", + "components.Layout.alphawarning": "Dies ist eine ALPHA-Software. Funktionen können kaputt und/oder instabil sein. Bitte melde Probleme auf GitHub!", "components.Login.signinplex": "Melde dich an, um fortzufahren", "components.MovieDetails.approve": "Genehmigen", "components.MovieDetails.available": "Verfügbar", @@ -25,13 +25,13 @@ "components.MovieDetails.cast": "Besetzung", "components.MovieDetails.decline": "Ablehnen", "components.MovieDetails.manageModalClearMedia": "Alle Mediendaten löschen", - "components.MovieDetails.manageModalClearMediaWarning": "Dadurch werden alle Mediendaten einschließlich aller Anfragen für dieses Element entfernt. Diese Aktion ist irreversibel. Wenn dieses Element in Ihrer Plex-Bibliothek vorhanden ist, werden die Medieninformationen bei der nächsten Synchronisierung neu erstellt.", + "components.MovieDetails.manageModalClearMediaWarning": "Dies wird unwiederbringlich alle Daten zu diesem Film, inklusive der Anfragen dafür, löschen. Falls dieses Element in deiner Plex Bibliothek existiert werden die Medieninformationen beim nächsten Synchronisieren neu erstellt.", "components.MovieDetails.manageModalNoRequests": "Keine Anfragen", "components.MovieDetails.manageModalRequests": "Anfragen", "components.MovieDetails.manageModalTitle": "Film verwalten", "components.MovieDetails.originallanguage": "Originalsprache", "components.MovieDetails.overview": "Übersicht", - "components.MovieDetails.overviewunavailable": "Übersicht nicht verfügbar", + "components.MovieDetails.overviewunavailable": "Übersicht nicht verfügbar.", "components.MovieDetails.pending": "Ausstehend", "components.MovieDetails.recommendations": "Empfehlungen", "components.MovieDetails.recommendationssubtext": "Wenn dir {title} gefallen hat, könnte dir auch gefallen …", @@ -50,7 +50,7 @@ "components.PersonDetails.nobiography": "Keine Biografie verfügbar.", "components.PlexLoginButton.loading": "Wird geladen …", "components.PlexLoginButton.loggingin": "Wird angemeldet …", - "components.PlexLoginButton.loginwithplex": "Anmeldung mit Plex", + "components.PlexLoginButton.loginwithplex": "Anmelden", "components.RequestBlock.seasons": "Staffeln", "components.RequestCard.all": "Alle", "components.RequestCard.requestedby": "Angefragt von {username}", @@ -68,14 +68,14 @@ "components.RequestList.status": "Status", "components.RequestModal.cancel": "Anfrage abbrechen", "components.RequestModal.cancelling": "Abbrechen …", - "components.RequestModal.cancelrequest": "Dadurch wird deine Anfrage entfernt. Bist du dir sicher, dass du weitermachen willst?", + "components.RequestModal.cancelrequest": "Dadurch wird deine Anfrage entfernt. Bist du sicher, dass du weitermachen willst?", "components.RequestModal.close": "Schließen", "components.RequestModal.extras": "Extras", "components.RequestModal.notrequested": "Nicht angefragt", "components.RequestModal.numberofepisodes": "Anzahl der Folgen", "components.RequestModal.pendingrequest": "Ausstehende Anfrage für {title}", "components.RequestModal.request": "Anfragen", - "components.RequestModal.requestCancel": "Anfrage für {title} abgebrochen", + "components.RequestModal.requestCancel": "Anfrage für {title} abgebrochen.", "components.RequestModal.requestSuccess": "{title} angefragt.", "components.RequestModal.requestadmin": "Deine Anfrage wird direkt genehmigt.", "components.RequestModal.requestfrom": "Es gibt derzeit eine ausstehende Anfrage von {username}", @@ -87,21 +87,21 @@ "components.RequestModal.selectseason": "Staffel(n) auswählen", "components.RequestModal.status": "Status", "components.Search.searchresults": "Suchergebnisse", - "components.Settings.Notifications.agentenabled": "Agent aktiviert", + "components.Settings.Notifications.agentenabled": "Aktiviere Agent", "components.Settings.Notifications.authPass": "SMTP-Passwort", "components.Settings.Notifications.authUser": "SMTP-Benutzername", - "components.Settings.Notifications.emailsender": "E-Mail-Absenderadresse", + "components.Settings.Notifications.emailsender": "Absenderadresse", "components.Settings.Notifications.enableSsl": "SSL aktivieren", "components.Settings.Notifications.save": "Änderungen speichern", "components.Settings.Notifications.saving": "Speichern …", "components.Settings.Notifications.smtpHost": "SMTP-Host", "components.Settings.Notifications.smtpPort": "SMTP-Port", - "components.Settings.Notifications.validationFromRequired": "Du musst eine E-Mail-Absenderadresse angeben.", - "components.Settings.Notifications.validationSmtpHostRequired": "Du musst einen SMTP-Host bereitstellen.", - "components.Settings.Notifications.validationSmtpPortRequired": "Du musst einen SMTP-Port bereitstellen.", - "components.Settings.Notifications.validationWebhookUrlRequired": "Du musst eine Webhook-URL angeben.", + "components.Settings.Notifications.validationFromRequired": "Du musst eine Absenderadresse angeben", + "components.Settings.Notifications.validationSmtpHostRequired": "Du musst einen SMTP-Host bereitstellen", + "components.Settings.Notifications.validationSmtpPortRequired": "Du musst einen SMTP-Port bereitstellen", + "components.Settings.Notifications.validationWebhookUrlRequired": "Du musst eine Webhook-URL angeben", "components.Settings.Notifications.webhookUrl": "Webhook-URL", - "components.Settings.Notifications.webhookUrlPlaceholder": "Servereinstellungen -> Integrationen -> Webhooks", + "components.Settings.Notifications.webhookUrlPlaceholder": "Servereinstellungen → Integrationen → WebHooks", "components.Settings.RadarrModal.add": "Server hinzufügen", "components.Settings.RadarrModal.apiKey": "API-Schlüssel", "components.Settings.RadarrModal.apiKeyPlaceholder": "Dein Radarr-API-Schlüssel", @@ -126,13 +126,13 @@ "components.Settings.RadarrModal.ssl": "SSL", "components.Settings.RadarrModal.test": "Test", "components.Settings.RadarrModal.testing": "Testen …", - "components.Settings.RadarrModal.toastRadarrTestFailure": "Verbindung zum Radarr-Server fehlgeschlagen", + "components.Settings.RadarrModal.toastRadarrTestFailure": "Verbindung zu Radarr fehlgeschlagen.", "components.Settings.RadarrModal.toastRadarrTestSuccess": "Radarr-Verbindung hergestellt!", - "components.Settings.RadarrModal.validationApiKeyRequired": "Du musst einen API-Schlüssel angeben.", - "components.Settings.RadarrModal.validationHostnameRequired": "Du musst einen Hostnamen/IP angeben.", - "components.Settings.RadarrModal.validationPortRequired": "Du musst einen Port angeben.", - "components.Settings.RadarrModal.validationProfileRequired": "Du musst ein Qualitätsprofil auswählen.", - "components.Settings.RadarrModal.validationRootFolderRequired": "Du musst einen Stammordner auswählen.", + "components.Settings.RadarrModal.validationApiKeyRequired": "Du musst einen API-Schlüssel angeben", + "components.Settings.RadarrModal.validationHostnameRequired": "Du musst einen Hostnamen/IP angeben", + "components.Settings.RadarrModal.validationPortRequired": "Du musst einen Port angeben", + "components.Settings.RadarrModal.validationProfileRequired": "Du musst ein Qualitätsprofil auswählen", + "components.Settings.RadarrModal.validationRootFolderRequired": "Du musst einen Stammordner auswählen", "components.Settings.SonarrModal.add": "Server hinzufügen", "components.Settings.SonarrModal.apiKey": "API-Schlüssel", "components.Settings.SonarrModal.apiKeyPlaceholder": "Dein Sonarr-API-Schlüssel", @@ -158,19 +158,19 @@ "components.Settings.SonarrModal.testing": "Testen …", "components.Settings.SonarrModal.toastRadarrTestFailure": "Es konnte keine Verbindung zum Sonarr-Server hergestellt werden", "components.Settings.SonarrModal.toastRadarrTestSuccess": "Sonarr-Verbindung hergestellt!", - "components.Settings.SonarrModal.validationApiKeyRequired": "Du musst einen API-Schlüssel angeben.", - "components.Settings.SonarrModal.validationHostnameRequired": "Du musst einen Hostnamen/IP angeben.", - "components.Settings.SonarrModal.validationPortRequired": "Du musst einen Port angeben.", - "components.Settings.SonarrModal.validationProfileRequired": "Du musst ein Qualitätsprofil auswählen.", - "components.Settings.SonarrModal.validationRootFolderRequired": "Du musst einen Stammordner auswählen.", + "components.Settings.SonarrModal.validationApiKeyRequired": "Du musst einen API-Schlüssel angeben", + "components.Settings.SonarrModal.validationHostnameRequired": "Du musst einen Hostnamen/IP angeben", + "components.Settings.SonarrModal.validationPortRequired": "Du musst einen Port angeben", + "components.Settings.SonarrModal.validationProfileRequired": "Du musst ein Qualitätsprofil auswählen", + "components.Settings.SonarrModal.validationRootFolderRequired": "Du musst einen Stammordner auswählen", "components.Settings.activeProfile": "Aktives Profil", "components.Settings.addradarr": "Radarr-Server hinzufügen", "components.Settings.address": "Adresse", "components.Settings.addsonarr": "Sonarr-Server hinzufügen", "components.Settings.apikey": "API-Schlüssel", "components.Settings.applicationurl": "Anwendungs-URL", - "components.Settings.cancelscan": "Scan abbrechen", - "components.Settings.copied": "API-Schlüssel in die Zwischenablage kopiert", + "components.Settings.cancelscan": "Durchsuchung abbrechen", + "components.Settings.copied": "API-Schlüssel in die Zwischenablage kopiert.", "components.Settings.currentlibrary": "Aktuelle Bibliothek: {name}", "components.Settings.default": "Standardmäßig", "components.Settings.default4k": "Standard-4K", @@ -178,12 +178,12 @@ "components.Settings.deleteserverconfirm": "Bist du sicher, dass du diesen Server löschen möchtest?", "components.Settings.edit": "Bearbeiten", "components.Settings.generalsettings": "Allgemeine Einstellungen", - "components.Settings.generalsettingsDescription": "Dies sind Einstellungen, die sich auf die allgemeine Overseerr-Konfiguration beziehen.", + "components.Settings.generalsettingsDescription": "Konfiguriere Globale und Standard Overseerr-Einstellungen.", "components.Settings.hostname": "Hostname/IP", "components.Settings.jobname": "Aufgabenname", "components.Settings.librariesRemaining": "Verbleibende Bibliotheken: {count}", - "components.Settings.manualscan": "Manueller Bibliotheksscan", - "components.Settings.manualscanDescription": "Normalerweise wird dies nur einmal alle 24 Stunden ausgeführt. Overseerr überprüft die kürzlich hinzugefügten Plex-Server aggressiver. Wenn du Plex zum ersten Mal konfigurierst, wird ein einmaliger vollständiger manueller Bibliotheksscan empfohlen!", + "components.Settings.manualscan": "Manuelle Bibliotheksdurchsuchung", + "components.Settings.manualscanDescription": "Normalerweise wird dies nur einmal alle 24 Stunden ausgeführt. Overseerr überprüft die kürzlich hinzugefügten Plex-Server aggressiver. Falls du Plex zum ersten Mal konfigurierst, wird eine einmalige vollständige manuelle Bibliotheksdurchsuchung empfohlen!", "components.Settings.menuAbout": "Über", "components.Settings.menuGeneralSettings": "Allgemeine Einstellungen", "components.Settings.menuJobs": "Aufgaben", @@ -193,24 +193,24 @@ "components.Settings.menuServices": "Dienste", "components.Settings.nextexecution": "Nächste Ausführung", "components.Settings.notificationsettings": "Benachrichtigungseinstellungen", - "components.Settings.notificationsettingsDescription": "Hier kannst du auswählen, welche Arten von Benachrichtigungen gesendet werden sollen und über welche Arten von Diensten.", + "components.Settings.notificationsettingsDescription": "Konfiguriere globale Benachrichtigungseinstellungen. Diese Einstellungen betreffen alle Benachrichtigungsagenten.", "components.Settings.notrunning": "Nicht aktiv", "components.Settings.plexlibraries": "Plex-Bibliotheken", - "components.Settings.plexlibrariesDescription": "Die Bibliotheken-Overseerr sucht nach Titeln. Richte deine Plex-Verbindungseinstellungen ein und speichere sie, klicke auf die Schaltfläche unten, wenn keine aufgeführt sind.", + "components.Settings.plexlibrariesDescription": "Die Bibliotheken, welche Overseerr nach Titeln durchsucht. Richte deine Plex-Verbindungseinstellungen ein und speichere sie, klicke auf die Schaltfläche unten, wenn keine aufgeführt sind.", "components.Settings.plexsettings": "Plex-Einstellungen", - "components.Settings.plexsettingsDescription": "Konfiguriere die Einstellungen für deinen Plex-Server. Overseerr verwendet den Plex-Server, um deine Bibliothek in regelmäßigen Abständen zu scannen und festzustellen, welche Inhalte verfügbar sind.", + "components.Settings.plexsettingsDescription": "Konfiguriere die Einstellungen für deinen Plex-Server. Overseerr durchsucht deine Plex-Bibliotheken, um festzustellen welche Inhalte verfügbar sind.", "components.Settings.port": "Port", "components.Settings.radarrSettingsDescription": "Richte unten deine Radarr-Verbindung ein. Du kannst mehrere, aber nur zwei standardmäßig, aktiv haben (eine für Standard-HD und eine für 4K). Administratoren können überschreiben, welcher Server für neue Anfragen verwendet wird.", "components.Settings.radarrsettings": "Radarr-Einstellungen", "components.Settings.runnow": "Jetzt ausführen", "components.Settings.save": "Änderungen speichern", "components.Settings.saving": "Speichern …", - "components.Settings.servername": "Servername (Wird nach dem Speichern automatisch festgelegt)", + "components.Settings.servername": "Servername", "components.Settings.servernamePlaceholder": "Plex-Servername", "components.Settings.sonarrSettingsDescription": "Richte unten deine Sonarr-Verbindung ein. Du kannst mehrere, aber nur zwei standardmäßig, aktiv haben (eine für Standard-HD und eine für 4K). Administratoren können überschreiben, welcher Server für neue Anfragen verwendet wird.", "components.Settings.sonarrsettings": "Sonarr-Einstellungen", "components.Settings.ssl": "SSL", - "components.Settings.startscan": "Scan starten", + "components.Settings.startscan": "Durchsuchung starten", "components.Settings.sync": "Plex-Bibliotheken synchronisieren", "components.Settings.syncing": "Synchronisierung …", "components.Setup.configureplex": "Plex konfigurieren", @@ -218,10 +218,10 @@ "components.Setup.continue": "Fortfahren", "components.Setup.finish": "Konfiguration beenden", "components.Setup.finishing": "Fertigstellung …", - "components.Setup.loginwithplex": "Anmeldung mit Plex", + "components.Setup.loginwithplex": "Mit Plex anmelden", "components.Setup.signinMessage": "Melde dich zunächst mit deinem Plex-Konto an", "components.Setup.welcome": "Willkommen bei Overseerr", - "components.Slider.noresults": "Keine Ergebnisse", + "components.Slider.noresults": "Keine Ergebnisse.", "components.TitleCard.movie": "Film", "components.TitleCard.tvshow": "Serie", "components.TvDetails.approve": "Genehmigen", @@ -232,13 +232,13 @@ "components.TvDetails.decline": "Ablehnen", "components.TvDetails.declinerequests": "{requestCount} {requestCount, plural, one {Anfrage} andere {Anfragen}} ablehnen", "components.TvDetails.manageModalClearMedia": "Alle Mediendaten löschen", - "components.TvDetails.manageModalClearMediaWarning": "Dadurch werden alle Mediendaten einschließlich aller Anfragen für dieses Element irreversibel entfernt. Wenn dieses Element in deiner Plex-Bibliothek vorhanden ist, werden die Medieninformationen bei der nächsten Synchronisierung neu erstellt.", + "components.TvDetails.manageModalClearMediaWarning": "Dies wird unwiederbringlich alle Daten zu dieser Serie, inklusive der Anfragen dafür, löschen. Falls dieses Element in deiner Plex Bibliothek existiert werden die Medieninformationen beim nächsten Synchronisieren neu erstellt.", "components.TvDetails.manageModalNoRequests": "Keine Anfragen", "components.TvDetails.manageModalRequests": "Anfragen", "components.TvDetails.manageModalTitle": "Serie verwalten", "components.TvDetails.originallanguage": "Originalsprache", "components.TvDetails.overview": "Übersicht", - "components.TvDetails.overviewunavailable": "Übersicht nicht verfügbar", + "components.TvDetails.overviewunavailable": "Übersicht nicht verfügbar.", "components.TvDetails.pending": "Ausstehend", "components.TvDetails.recommendations": "Empfehlungen", "components.TvDetails.recommendationssubtext": "Wenn dir {title} gefallen hat, könnte dir auch gefallen …", @@ -266,10 +266,10 @@ "components.UserEdit.settings": "Einstellungen verwalten", "components.UserEdit.settingsDescription": "Erteilt die Berechtigung zum Ändern aller Overseerr-Einstellungen. Ein Benutzer muss über diese Berechtigung verfügen, um sie anderen Benutzern erteilen zu können.", "components.UserEdit.userfail": "Beim Speichern des Benutzers ist etwas schief gelaufen.", - "components.UserEdit.username": "Benutzername", + "components.UserEdit.username": "Anzeigename", "components.UserEdit.users": "Benutzer verwalten", "components.UserEdit.usersDescription": "Erteilt die Berechtigung zum Verwalten von Overseerr-Benutzern. Benutzer mit dieser Berechtigung können Benutzer mit Administratorrechten nicht bearbeiten oder es gewähren.", - "components.UserEdit.usersaved": "Benutzer gespeichert", + "components.UserEdit.usersaved": "Benutzer gespeichert!", "components.UserEdit.vote": "Abstimmen", "components.UserEdit.voteDescription": "Erteilt die Erlaubnis, über Anfragen abzustimmen (Abstimmungen noch nicht implementiert)", "components.UserList.admin": "Admin", @@ -309,22 +309,22 @@ "components.Settings.Notifications.emailsettingsfailed": "E-Mail-Benachrichtigungseinstellungen konnten nicht gespeichert werden.", "components.Settings.Notifications.discordsettingssaved": "Discord-Benachrichtigungseinstellungen gespeichert!", "components.Settings.Notifications.discordsettingsfailed": "Discord-Benachrichtigungseinstellungen konnten nicht gespeichert werden.", - "components.Settings.validationPortRequired": "Du musst einen Port angeben.", - "components.Settings.validationHostnameRequired": "Du musst einen Hostnamen/IP angeben.", - "components.Settings.SonarrModal.validationNameRequired": "Du musst einen Servernamen angeben.", + "components.Settings.validationPortRequired": "Du musst einen Port angeben", + "components.Settings.validationHostnameRequired": "Du musst einen Hostnamen/IP angeben", + "components.Settings.SonarrModal.validationNameRequired": "Du musst einen Servernamen angeben", "components.Settings.SettingsAbout.version": "Version", "components.Settings.SettingsAbout.totalrequests": "Anfragen insgesamt", "components.Settings.SettingsAbout.totalmedia": "Medien insgesamt", "components.Settings.SettingsAbout.overseerrinformation": "Overseerr-Informationen", "components.Settings.SettingsAbout.githubdiscussions": "GitHub-Diskussionen", "components.Settings.SettingsAbout.gettingsupport": "Hilfe erhalten", - "components.Settings.SettingsAbout.clickheretojoindiscord": "Klicke hier, um unserem Discord-Server beizutreten.", - "components.Settings.RadarrModal.validationNameRequired": "Du musst einen Servernamen angeben.", + "components.Settings.SettingsAbout.clickheretojoindiscord": "Klicke hier, um unserem Discord-Server beizutreten!", + "components.Settings.RadarrModal.validationNameRequired": "Du musst einen Servernamen angeben", "components.Setup.tip": "Tipp", "components.Setup.syncingbackground": "Die Synchronisierung wird im Hintergrund ausgeführt. Du kannst die Konfiguration in der Zwischenzeit fortsetzen.", "i18n.deleting": "Löschen …", - "components.UserList.userdeleteerror": "Beim Löschen des Benutzers ist etwas schief gelaufen", - "components.UserList.userdeleted": "Benutzer gelöscht", + "components.UserList.userdeleteerror": "Beim Löschen des Benutzers ist etwas schief gelaufen.", + "components.UserList.userdeleted": "Benutzer gelöscht.", "components.UserList.deleteuser": "Benutzer löschen", "components.UserList.deleteconfirm": "Willst du diesen Benutzer wirklich löschen? Alle vorhandenen Anfragendaten dieses Benutzers werden entfernt.", "components.Settings.nodefaultdescription": "Mindestens ein Server muss als Standard markiert sein, bevor Anfragen an deine Dienste weitergeleitet werden.", @@ -334,7 +334,7 @@ "components.Settings.SonarrModal.testFirstQualityProfiles": "Teste die Verbindung, um Qualitätsprofile zu laden", "components.Settings.SonarrModal.loadingrootfolders": "Stammordner werden geladen …", "components.Settings.SonarrModal.loadingprofiles": "Qualitätsprofile werden geladen …", - "components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "Du musst eine Mindestverfügbarkeit auswählen.", + "components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "Du musst eine Mindestverfügbarkeit auswählen", "components.Settings.RadarrModal.testFirstRootFolders": "Teste die Verbindung, um Stammordner zu laden", "components.Settings.RadarrModal.testFirstQualityProfiles": "Teste die Verbindung, um Qualitätsprofile zu laden", "components.Settings.RadarrModal.loadingrootfolders": "Stammordner werden geladen …", @@ -365,10 +365,10 @@ "components.Settings.Notifications.testsent": "Testbenachrichtigung gesendet!", "components.Settings.Notifications.test": "Test", "components.Settings.defaultPermissions": "Standardbenutzerberechtigungen", - "components.UserList.importfromplexerror": "Beim Importieren von Benutzern aus Plex ist etwas schief gelaufen", + "components.UserList.importfromplexerror": "Beim Importieren von Benutzern aus Plex ist etwas schief gelaufen.", "components.UserList.importfromplex": "Benutzer aus Plex importieren", "components.TvDetails.viewfullcrew": "Komplette Crew anzeigen", - "components.TvDetails.TvCrew.fullseriescrew": "Komplette Serien Crew", + "components.TvDetails.TvCrew.fullseriescrew": "Komplette Serien-Crew", "components.PersonDetails.crewmember": "Crewmitglied", "components.MovieDetails.viewfullcrew": "Komplette Crew anzeigen", "components.MovieDetails.MovieCrew.fullcrew": "Komplette Crew", @@ -384,14 +384,14 @@ "components.CollectionDetails.requestcollection": "Sammlung anfragen", "components.CollectionDetails.requestSuccess": "{title} erfolgreich angefragt!", "components.CollectionDetails.request": "Anfragen", - "components.CollectionDetails.overviewunavailable": "Übersicht nicht verfügbar", + "components.CollectionDetails.overviewunavailable": "Übersicht nicht verfügbar.", "components.CollectionDetails.overview": "Übersicht", "components.CollectionDetails.numberofmovies": "Anzahl der Filme: {count}", "components.CollectionDetails.movies": "Filme", "i18n.requested": "Angefragt", "i18n.retry": "Wiederholen", "i18n.failed": "Fehlgeschlagen", - "components.RequestList.RequestItem.failedretry": "Beim Wiederholen der Anfrage ist etwas schief gelaufen", + "components.RequestList.RequestItem.failedretry": "Beim Wiederholen der Anfrage ist etwas schief gelaufen.", "components.Settings.Notifications.NotificationsSlack.settingupslackDescription": "Um Slack-Benachrichtigungen zu verwenden, musst du eine Incoming Webhook-Integration erstellen und die unten angegebene Webhook-URL verwenden.", "components.Settings.Notifications.NotificationsSlack.webhookUrlPlaceholder": "Webhook URL", "components.Settings.Notifications.NotificationsSlack.webhookUrl": "Webhook URL", @@ -402,14 +402,14 @@ "components.Settings.Notifications.NotificationsSlack.settingupslack": "Einrichten von Slack-Benachrichtigungen", "components.Settings.Notifications.NotificationsSlack.saving": "Speichern …", "components.Settings.Notifications.NotificationsSlack.save": "Änderungen speichern", - "components.Settings.Notifications.NotificationsSlack.agentenabled": "Agent aktiviert", + "components.Settings.Notifications.NotificationsSlack.agentenabled": "Agent aktivieren", "components.UserEdit.autoapproveSeries": "Automatische Genehmigung von Serien", "components.UserEdit.autoapproveMovies": "Automatische Genehmigung von Filmen", "components.UserEdit.autoapproveSeriesDescription": "Gewährt die automatische Genehmigung für Serienanfragen von diesem Benutzer.", "components.UserEdit.autoapproveMoviesDescription": "Gewährt die automatische Genehmigung für Filmanfragen von diesem Benutzer.", - "components.Settings.Notifications.NotificationsSlack.validationWebhookUrlRequired": "Du musst eine Webhook-URL angeben.", - "components.Settings.Notifications.validationChatIdRequired": "Du musst eine Chat-ID angeben.", - "components.Settings.Notifications.validationBotAPIRequired": "Du musst einen Bot-API-Schlüssel angeben.", + "components.Settings.Notifications.NotificationsSlack.validationWebhookUrlRequired": "Du musst eine Webhook-URL angeben", + "components.Settings.Notifications.validationChatIdRequired": "Du musst eine Chat-ID angeben", + "components.Settings.Notifications.validationBotAPIRequired": "Du musst einen Bot-API-Schlüssel angeben", "components.Settings.Notifications.telegramsettingssaved": "Telegram-Benachrichtigungseinstellungen gespeichert!", "components.Settings.Notifications.telegramsettingsfailed": "Telegram-Benachrichtigungseinstellungen konnten nicht gespeichert werden.", "components.Settings.Notifications.senderName": "Absendername", @@ -433,18 +433,18 @@ "components.NotificationTypeSelector.mediaapproved": "Medien genehmigt", "i18n.request": "Anfragen", "components.Settings.Notifications.NotificationsPushover.settinguppushoverDescription": "Um Pushover einzurichten, musst du eine Anwendung registrieren und das Zugriffstoken erhalten. Beim Einrichten der Anwendung kannst du eines der Symbole im öffentlichen Ordner auf GitHub verwenden. Du benötigst auch das Pushover-Benutzertoken, welches du auf der Startseite findest, wenn du dich anmeldest.", - "components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "Du musst ein Benutzertoken bereitstellen.", - "components.Settings.Notifications.NotificationsPushover.validationAccessTokenRequired": "Du musst ein Zugriffstoken bereitstellen.", + "components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "Du musst ein Benutzertoken bereitstellen", + "components.Settings.Notifications.NotificationsPushover.validationAccessTokenRequired": "Du musst ein Zugriffstoken bereitstellen", "components.Settings.Notifications.NotificationsPushover.userToken": "Benutzertoken", "components.Settings.Notifications.NotificationsPushover.testsent": "Test-Benachrichtigung gesendet!", "components.Settings.Notifications.NotificationsPushover.test": "Test", - "components.Settings.Notifications.NotificationsPushover.settinguppushover": "Pushover-Benachrichtigungseinstellungen", + "components.Settings.Notifications.NotificationsPushover.settinguppushover": "Einrichten von Pushover-Benachrichtigungen", "components.Settings.Notifications.NotificationsPushover.saving": "Speichern …", "components.Settings.Notifications.NotificationsPushover.save": "Änderungen speichern", "components.Settings.Notifications.NotificationsPushover.pushoversettingssaved": "Pushover-Benachrichtigungseinstellungen gespeichert!", "components.Settings.Notifications.NotificationsPushover.notificationtypes": "Benachrichtigungsarten", "components.Settings.Notifications.NotificationsPushover.pushoversettingsfailed": "Pushover-Benachrichtigungseinstellungen konnten nicht gespeichert werden.", - "components.Settings.Notifications.NotificationsPushover.agentenabled": "Agent aktiviert", + "components.Settings.Notifications.NotificationsPushover.agentenabled": "Agent aktivieren", "components.Settings.Notifications.NotificationsPushover.accessToken": "Zugangstoken", "components.RequestList.sortModified": "Zuletzt geändert", "components.RequestList.sortAdded": "Anfragedatum", @@ -464,21 +464,21 @@ "components.Settings.Notifications.NotificationsWebhook.webhooksettingssaved": "Webhook-Benachrichtigungseinstellungen gespeichert!", "components.Settings.Notifications.NotificationsWebhook.webhookUrlPlaceholder": "Entfernte Webhook-URL", "components.Settings.Notifications.NotificationsWebhook.webhookUrl": "Webhook-URL", - "components.Settings.Notifications.NotificationsWebhook.validationWebhookUrlRequired": "Du musst eine Webhook-URL angeben.", - "components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "Du musst einen JSON-Inhalt angeben.", + "components.Settings.Notifications.NotificationsWebhook.validationWebhookUrlRequired": "Du musst eine Webhook-URL angeben", + "components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "Du musst einen JSON-Inhalt angeben", "components.Settings.Notifications.NotificationsWebhook.testsent": "Test Benachrichtigung versendet!", "components.Settings.Notifications.NotificationsWebhook.test": "Test", "components.Settings.Notifications.NotificationsWebhook.templatevariablehelp": "Hilfe zu Vorlagenvariablen", "components.Settings.Notifications.NotificationsWebhook.saving": "Speichern …", "components.Settings.Notifications.NotificationsWebhook.save": "Änderungen speichern", - "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON zum Standard-Inhalt zurückgesetzt.", - "components.Settings.Notifications.NotificationsWebhook.resetPayload": "Auf Standard-JSON-Inhalt zurücksetzen", + "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON-Inhalt erfolgreich zurückgesetzt.", + "components.Settings.Notifications.NotificationsWebhook.resetPayload": "Auf Standard zurücksetzen", "components.Settings.Notifications.NotificationsWebhook.notificationtypes": "Benachrichtigungsarten", "components.Settings.Notifications.NotificationsWebhook.customJson": "Benutzerdefinierter JSON-Inhalt", "components.Settings.Notifications.NotificationsWebhook.authheader": "Autorisierungsheader", - "components.Settings.Notifications.NotificationsWebhook.agentenabled": "Agent aktiviert", + "components.Settings.Notifications.NotificationsWebhook.agentenabled": "Aktiviere Agent", "components.RequestModal.request4ktitle": "{title} in 4K anfragen", - "components.RequestModal.request4kfrom": "Es gibt derzeit eine ausstehende 4K Anfrage von {username}", + "components.RequestModal.request4kfrom": "Es gibt derzeit eine ausstehende 4K Anfrage von {username}.", "components.RequestModal.request4k": "4K anfragen", "components.RequestModal.pending4krequest": "Ausstehende Anfrage für {title} in 4K", "components.RequestButton.viewrequest4k": "4K Anfrage anzeigen", @@ -497,11 +497,11 @@ "components.RequestButton.approve4krequests": "Genehmige {requestCount} 4K {requestCount, plural, one {Anfrage} other {Anfragen}}", "components.UserList.creating": "Erstelle", "components.UserList.autogeneratepassword": "Generiere Passwort automatisch", - "components.UserList.validationpasswordminchars": "Passwort ist zu kurz; es sollte mindestens 8 Zeichen lang sein.", - "components.UserList.validationemailrequired": "Erfordert eine gültige E-Mail-Adresse.", - "components.UserList.usercreatedsuccess": "Benutzer wurde erfolgreich erstellt", - "components.UserList.usercreatedfailed": "Beim Erstellen des Benutzers ist etwas schief gelaufen", - "components.UserList.passwordinfodescription": "E-Mail-Benachrichtigungseinstellungen müssen eingerichtet und aktiviert sein, um automatische Passwortgeneration benutzen zu können", + "components.UserList.validationpasswordminchars": "Passwort ist zu kurz; es sollte mindestens 8 Zeichen lang sein", + "components.UserList.validationemailrequired": "Erfordert eine gültige E-Mail-Adresse", + "components.UserList.usercreatedsuccess": "Benutzer wurde erfolgreich erstellt!", + "components.UserList.usercreatedfailed": "Beim Erstellen des Benutzers ist etwas schief gelaufen.", + "components.UserList.passwordinfodescription": "E-Mail-Benachrichtigungen müssen eingerichtet und aktiviert sein, um automatische Passwortgeneration benutzen zu können.", "components.UserList.passwordinfo": "Passwort Informationen", "components.UserList.password": "Passwort", "components.UserList.localuser": "Lokaler Benutzer", @@ -511,16 +511,16 @@ "components.UserList.create": "Erstellen", "components.Login.validationpasswordrequired": "Passwort erforderlich", "components.Login.validationemailrequired": "Keine gültige E-Mail-Adresse", - "components.Login.signinwithoverseerr": "Mit Overseerr anmelden", + "components.Login.signinwithoverseerr": "Benutze deinen Overseerr-Konto", "components.Login.password": "Passwort", - "components.Login.loginerror": "Beim Anmelden ist etwas schief gelaufen", + "components.Login.loginerror": "Beim Anmelden ist etwas schief gelaufen.", "components.Login.login": "Anmelden", "components.Login.loggingin": "Anmelden …", "components.Login.goback": "Zurück", "components.Login.email": "E-Mail-Adresse", "components.MediaSlider.ShowMoreCard.seemore": "Mehr anzeigen", "components.UserEdit.advancedrequestDescription": "Gewährt die Berechtigung erweiterte Anfragen zu benutzen. (z.B. Server/Profile/Pfade zu ändern)", - "components.RequestBlock.requestoverrides": "Anfrage überschreiben", + "components.RequestBlock.requestoverrides": "Anfrage Überschreibungen", "i18n.edit": "Bearbeiten", "components.UserEdit.advancedrequest": "Erweiterte Anfragen", "components.RequestModal.requestedited": "Anfrage bearbeitet.", @@ -528,13 +528,114 @@ "components.RequestModal.errorediting": "Beim Bearbeiten der Anfrage ist etwas schief gelaufen.", "components.RequestModal.AdvancedRequester.rootfolder": "Stammordner", "components.RequestModal.AdvancedRequester.qualityprofile": "Qualitätsprofil", - "components.RequestModal.AdvancedRequester.loadingprofiles": "Lade Profile…", - "components.RequestModal.AdvancedRequester.loadingfolders": "Lade Ordner…", + "components.RequestModal.AdvancedRequester.loadingprofiles": "Lade Profile …", + "components.RequestModal.AdvancedRequester.loadingfolders": "Lade Ordner …", "components.RequestModal.AdvancedRequester.destinationserver": "Zielserver", "components.RequestModal.AdvancedRequester.default": "(Standard)", "components.RequestModal.AdvancedRequester.animenote": "* Diese Serie ist ein Anime.", "components.RequestModal.AdvancedRequester.advancedoptions": "Erweiterte Einstellungen", "components.RequestBlock.server": "Server", "components.RequestBlock.rootfolder": "Stammordner", - "components.RequestBlock.profilechanged": "Profil geändert" + "components.RequestBlock.profilechanged": "Profil geändert", + "components.NotificationTypeSelector.mediadeclined": "Medien abgelehnt", + "components.NotificationTypeSelector.mediadeclinedDescription": "Sendet eine Benachrichtigung, wenn eine Anfrage abgelehnt wurde.", + "components.RequestModal.autoapproval": "Automatische Genehmigung", + "i18n.experimental": "Experimental", + "components.Settings.hideAvailable": "Verfügbare Medien ausblenden", + "components.RequestModal.SearchByNameModal.next": "Weiter", + "components.RequestModal.next": "Weiter", + "components.RequestModal.requesterror": "Beim Senden der Anfragen ist etwas schief gelaufen.", + "components.RequestModal.SearchByNameModal.notvdbiddescription": "Wir konnten deine Anfrage nicht automatisch zuordnen. Bitte wähle eine korrekte Übereinstimmung aus der Liste aus:", + "components.RequestModal.notvdbiddescription": "Füge entweder die TVDB-ID zu TMDB hinzu und kehre später zurück, oder wähle unten die richtige Übereinstimmung aus:", + "components.RequestModal.notvdbid": "In TMDb wurde keine TVDB-ID gefunden.", + "components.RequestModal.backbutton": "Zurück", + "components.RequestModal.SearchByNameModal.notvdbid": "Manuelle Zuordnung erforderlich", + "components.RequestModal.SearchByNameModal.nosummary": "Keine Zusammenfassung für diesen Titel gefunden.", + "components.Login.signinwithplex": "Benutze dein Plex-Konto", + "components.Login.signinheader": "Anmelden um fortzufahren", + "components.Login.signingin": "Anmelden …", + "components.Login.signin": "Anmelden", + "components.Settings.notificationsettingsfailed": "Benachrichtigungseinstellungen konnten nicht gespeichert werden.", + "components.Settings.notificationsettingssaved": "Benachrichtigungseinstellungen gespeichert!", + "components.Settings.notificationAgentsSettings": "Benachrichtigungsagenten", + "components.Settings.notificationAgentSettingsDescription": "Wähle aus, welche Arten von Benachrichtigungen mit welchen Agenten gesendet werden sollen.", + "components.Settings.enablenotifications": "Aktiviere Benachrichtigungen", + "components.Settings.autoapprovedrequests": "Sende Benachrichtigungen für automatisch genehmigte Anfragen", + "components.PlexLoginButton.signinwithplex": "Anmelden", + "components.PlexLoginButton.signingin": "Anmelden …", + "components.PermissionEdit.autoapproveSeries": "Automatische Genehmigung von Serien", + "components.PermissionEdit.autoapprove": "Automatische Genehmigung", + "components.PermissionEdit.advancedrequest": "Erweiterte Anfragen", + "components.PermissionEdit.managerequests": "Anfragen verwalten", + "components.PermissionEdit.request": "Anfrage", + "components.PermissionEdit.autoapproveMovies": "Automatische Genehmigung von Filmen", + "components.PermissionEdit.admin": "Administrator", + "components.PermissionEdit.managerequestsDescription": "Gewährt die Berechtigung Overseerr-Anfragen zu verwalten. Dies schließt Genehmigen und Ablehnen von Anfragen mit ein.", + "components.Settings.timeout": "Zeitüberschreitung", + "components.Settings.ms": "ms", + "components.UserList.userssaved": "Benutzer gespeichert!", + "components.UserList.bulkedit": "Ausgewählte bearbeiten", + "components.Settings.toastPlexRefreshSuccess": "Plex-Serverliste abgerufen.", + "components.Settings.toastPlexRefreshFailure": "Plex-Serverliste konnte nicht abgerufen werden!", + "components.Settings.toastPlexRefresh": "Abrufen der Serverliste von Plex…", + "components.Settings.toastPlexConnectingSuccess": "Verbunden mit dem Plex Server.", + "components.Settings.toastPlexConnectingFailure": "Verbindung zu Plex nicht möglich!", + "components.Settings.toastPlexConnecting": "Versuche mit Plex zu verbinden …", + "components.Settings.settingUpPlexDescription": "Um Plex einzurichten, kannst du deine Daten manuell eintragen oder einen Server auswählen, welcher von plex.tv abgerufen wurde. Drück den Knopf rechts neben dem Dropdown-Menü, um die Verbindung zu überprüfen und verfügbare Server abzurufen.", + "components.Settings.servernameTip": "Wird nach dem Speichern automatisch von Plex abgerufen", + "components.Settings.settingUpPlex": "Plex einrichten", + "components.Settings.serverpresetRefreshing": "Rufe Server ab…", + "components.Settings.serverpresetPlaceholder": "Plex Server", + "components.Settings.serverpresetManualMessage": "Manuell festlegen", + "components.Settings.serverpresetLoad": "Drück den Knopf, um verfügbare Server zu laden", + "components.Settings.serverpreset": "Server", + "components.Settings.serverRemote": "entfernt", + "components.Settings.serverLocal": "lokal", + "components.Settings.serverConnected": "verbunden", + "components.Settings.csrfProtectionTip": "Macht den externen API Zugang schreibgeschützt (Overseerr muss neu gestartet werden, damit die Änderungen wirksam werden)", + "components.Settings.csrfProtection": "Aktiviere CSRF Schutz", + "components.Settings.SonarrModal.toastSonarrTestSuccess": "Verbindung zu Sonarr hergestellt!", + "components.Settings.SonarrModal.toastSonarrTestFailure": "Verbindung zu Sonarr fehlgeschlagen.", + "components.PermissionEdit.voteDescription": "Gewährt die Berechtigung zum Abstimmen über Anfragen (Abstimmungen sind noch nicht implementiert)", + "components.PermissionEdit.vote": "Abstimmen", + "components.PermissionEdit.usersDescription": "Gewährt die Berechtigung zum Verwalten von Overseerr-Benutzern. Benutzer mit dieser Berechtigung können Benutzer mit Administratorrechten nicht bearbeiten oder sie gewähren.", + "components.PermissionEdit.users": "Benutzer verwalten", + "components.PermissionEdit.settingsDescription": "Gewährt die Berechtigung zum Ändern aller Overseerr-Einstellungen. Ein Benutzer muss über diese Berechtigung verfügen, um sie anderen Benutzern erteilen zu können.", + "components.PermissionEdit.settings": "Einstellungen verwalten", + "components.PermissionEdit.requestDescription": "Gewährt die Berechtigung zum Anfragen von Filmen und Serien.", + "components.PermissionEdit.request4kTvDescription": "Gewährt die Berechtigung Serien in 4K anzufragen.", + "components.PermissionEdit.request4kTv": "4K Serien anfragen", + "components.PermissionEdit.request4kMoviesDescription": "Gewährt die Berechtigung Filme in 4K anzufragen.", + "components.PermissionEdit.request4kMovies": "4K Filme anfragen", + "components.PermissionEdit.request4k": "4K anfragen", + "components.PermissionEdit.request4kDescription": "Gewährt die Berechtigung Filme und Serien in 4K anzufragen.", + "components.PermissionEdit.autoapproveSeriesDescription": "Gewährt die automatische Genehmigung für Serienanfragen von diesem Benutzer.", + "components.PermissionEdit.autoapproveMoviesDescription": "Gewährt die automatische Genehmigung für Filmanfragen von diesem Benutzer.", + "components.PermissionEdit.autoapproveDescription": "Gewährt die automatische Genehmigung für alle Anfragen von diesem Benutzer.", + "components.PermissionEdit.advancedrequestDescription": "Gewährt die Berechtigung die erweiterten Anfrageoptionen zu benutzen; z.B.: Server, Profile oder Pfade zu verändern.", + "components.PermissionEdit.adminDescription": "Voller Administratorzugriff. Umgeht alle Rechteabfragen.", + "components.Common.ListView.noresults": "Keine Ergebnisse.", + "components.MovieDetails.areyousure": "Bist du sicher?", + "components.MovieDetails.openradarr4k": "Film in 4K Radarr öffnen", + "components.MovieDetails.openradarr": "Film in Radarr öffnen", + "components.UserEdit.plexUsername": "Plex-Benutzername", + "components.TvDetails.opensonarr4k": "Serie in 4K Sonarr öffnen", + "components.TvDetails.opensonarr": "Serie in Sonarr öffnen", + "components.TvDetails.downloadstatus": "Herunterladen-Status", + "components.TvDetails.areyousure": "Bist du sicher?", + "components.Settings.jobtype": "Art", + "components.Settings.jobstarted": "{jobname} gestartet.", + "components.Settings.jobcancelled": "{jobname} abgebrochen.", + "components.Settings.canceljob": "Aufgabe abbrechen", + "components.Settings.SonarrModal.syncEnabled": "Synchronisation aktivieren", + "components.Settings.SonarrModal.preventSearch": "Automatische Suche deaktivieren", + "components.Settings.SonarrModal.externalUrlPlaceholder": "Externe URL, welche auf deinen Sonarr-Server verweist", + "components.Settings.SonarrModal.externalUrl": "Externe URL", + "components.Settings.RadarrModal.syncEnabled": "Synchronisation aktivieren", + "components.Settings.RadarrModal.preventSearch": "Automatische Suche deaktivieren", + "components.Settings.RadarrModal.externalUrlPlaceholder": "Externe URL, welche auf deinen Radarr-Server verweist", + "components.Settings.RadarrModal.externalUrl": "Externe URL", + "components.MovieDetails.downloadstatus": "Herunterladen-Status", + "components.TvDetails.playonplex": "Auf Plex abspielen", + "components.MovieDetails.playonplex": "Auf Plex abspielen" } diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index d5c3d330..840523fb 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -2,12 +2,13 @@ "components.CollectionDetails.movies": "Movies", "components.CollectionDetails.numberofmovies": "Number of Movies: {count}", "components.CollectionDetails.overview": "Overview", - "components.CollectionDetails.overviewunavailable": "Overview unavailable", + "components.CollectionDetails.overviewunavailable": "Overview unavailable.", "components.CollectionDetails.request": "Request", "components.CollectionDetails.requestSuccess": "{title} successfully requested!", "components.CollectionDetails.requestcollection": "Request Collection", "components.CollectionDetails.requesting": "Requesting…", "components.CollectionDetails.requestswillbecreated": "The following titles will have requests created for them:", + "components.Common.ListView.noresults": "No results.", "components.Discover.discovermovies": "Popular Movies", "components.Discover.discovertv": "Popular Series", "components.Discover.nopending": "No Pending Requests", @@ -25,35 +26,43 @@ "components.Layout.Sidebar.settings": "Settings", "components.Layout.Sidebar.users": "Users", "components.Layout.UserDropdown.signout": "Sign Out", - "components.Layout.alphawarning": "This is ALPHA software. Almost everything is bound to be nearly broken and/or unstable. Please report issues to the Overseerr GitHub!", + "components.Layout.alphawarning": "This is ALPHA software. Features may be broken and/or unstable. Please report issues on GitHub!", "components.Login.email": "Email Address", - "components.Login.goback": "Go back", - "components.Login.loggingin": "Logging in…", - "components.Login.login": "Login", - "components.Login.loginerror": "Something went wrong when trying to sign in", + "components.Login.loginerror": "Something went wrong while trying to sign in.", "components.Login.password": "Password", - "components.Login.signinplex": "Sign in to continue", - "components.Login.signinwithoverseerr": "Sign in with Overseerr", + "components.Login.signin": "Sign In", + "components.Login.signingin": "Signing in…", + "components.Login.signinheader": "Sign in to continue", + "components.Login.signinwithoverseerr": "Use your Overseerr account", + "components.Login.signinwithplex": "Use your Plex account", "components.Login.validationemailrequired": "Not a valid email address", "components.Login.validationpasswordrequired": "Password required", "components.MediaSlider.ShowMoreCard.seemore": "See More", "components.MovieDetails.MovieCast.fullcast": "Full Cast", "components.MovieDetails.MovieCrew.fullcrew": "Full Crew", "components.MovieDetails.approve": "Approve", + "components.MovieDetails.areyousure": "Are you sure?", "components.MovieDetails.available": "Available", "components.MovieDetails.budget": "Budget", "components.MovieDetails.cancelrequest": "Cancel Request", "components.MovieDetails.cast": "Cast", "components.MovieDetails.decline": "Decline", + "components.MovieDetails.downloadstatus": "Download Status", "components.MovieDetails.manageModalClearMedia": "Clear All Media Data", - "components.MovieDetails.manageModalClearMediaWarning": "This will remove all media data including all requests for this item irreversibly. If this item exists in your Plex library, the media info will be recreated next sync.", + "components.MovieDetails.manageModalClearMediaWarning": "This will irreversibly remove all data for this movie, including any requests. If this item exists in your Plex library, the media information will be recreated during the next sync.", "components.MovieDetails.manageModalNoRequests": "No Requests", "components.MovieDetails.manageModalRequests": "Requests", "components.MovieDetails.manageModalTitle": "Manage Movie", + "components.MovieDetails.mark4kavailable": "Mark 4K as Available", + "components.MovieDetails.markavailable": "Mark as Available", + "components.MovieDetails.openradarr": "Open Movie in Radarr", + "components.MovieDetails.openradarr4k": "Open Movie in 4K Radarr", "components.MovieDetails.originallanguage": "Original Language", "components.MovieDetails.overview": "Overview", - "components.MovieDetails.overviewunavailable": "Overview unavailable", + "components.MovieDetails.overviewunavailable": "Overview unavailable.", "components.MovieDetails.pending": "Pending", + "components.MovieDetails.play4konplex": "Play 4K on Plex", + "components.MovieDetails.playonplex": "Play on Plex", "components.MovieDetails.recommendations": "Recommendations", "components.MovieDetails.recommendationssubtext": "If you liked {title}, you might also like…", "components.MovieDetails.releasedate": "Release Date", @@ -78,13 +87,39 @@ "components.NotificationTypeSelector.mediafailedDescription": "Sends a notification when media fails to be added to services (Radarr/Sonarr). For certain agents, this will only send the notification to admins or users with the \"Manage Requests\" permission.", "components.NotificationTypeSelector.mediarequested": "Media Requested", "components.NotificationTypeSelector.mediarequestedDescription": "Sends a notification when new media is requested. For certain agents, this will only send the notification to admins or users with the \"Manage Requests\" permission.", + "components.PermissionEdit.admin": "Admin", + "components.PermissionEdit.adminDescription": "Full administrator access. Bypasses all permission checks.", + "components.PermissionEdit.advancedrequest": "Advanced Requests", + "components.PermissionEdit.advancedrequestDescription": "Grants permission to use advanced request options; e.g. changing servers, profiles, or paths.", + "components.PermissionEdit.autoapprove": "Auto-Approve", + "components.PermissionEdit.autoapproveDescription": "Grants automatic approval for all requests made by this user.", + "components.PermissionEdit.autoapproveMovies": "Auto-Approve Movies", + "components.PermissionEdit.autoapproveMoviesDescription": "Grants automatic approval for movie requests made by this user.", + "components.PermissionEdit.autoapproveSeries": "Auto-Approve Series", + "components.PermissionEdit.autoapproveSeriesDescription": "Grants automatic approval for series requests made by this user.", + "components.PermissionEdit.managerequests": "Manage Requests", + "components.PermissionEdit.managerequestsDescription": "Grants permission to manage Overseerr requests. This includes approving and denying requests.", + "components.PermissionEdit.request": "Request", + "components.PermissionEdit.request4k": "Request 4K", + "components.PermissionEdit.request4kDescription": "Grants permission to request 4K movies and series.", + "components.PermissionEdit.request4kMovies": "Request 4K Movies", + "components.PermissionEdit.request4kMoviesDescription": "Grants permission to request 4K movies.", + "components.PermissionEdit.request4kTv": "Request 4K Series", + "components.PermissionEdit.request4kTvDescription": "Grants permission to request 4K Series.", + "components.PermissionEdit.requestDescription": "Grants permission to request movies and series.", + "components.PermissionEdit.settings": "Manage Settings", + "components.PermissionEdit.settingsDescription": "Grants permission to modify all Overseerr settings. A user must have this permission to grant it to others.", + "components.PermissionEdit.users": "Manage Users", + "components.PermissionEdit.usersDescription": "Grants permission to manage Overseerr users. Users with this permission cannot modify users with Administrator privilege, or grant it.", + "components.PermissionEdit.vote": "Vote", + "components.PermissionEdit.voteDescription": "Grants permission to vote on requests (voting not yet implemented)", "components.PersonDetails.appearsin": "Appears in", "components.PersonDetails.ascharacter": "as {character}", "components.PersonDetails.crewmember": "Crew Member", "components.PersonDetails.nobiography": "No biography available.", "components.PlexLoginButton.loading": "Loading…", - "components.PlexLoginButton.loggingin": "Logging in…", - "components.PlexLoginButton.loginwithplex": "Login with Plex", + "components.PlexLoginButton.signingin": "Signing in…", + "components.PlexLoginButton.signinwithplex": "Sign In", "components.RequestBlock.profilechanged": "Profile Changed", "components.RequestBlock.requestoverrides": "Request Overrides", "components.RequestBlock.rootfolder": "Root Folder", @@ -107,7 +142,7 @@ "components.RequestCard.all": "All", "components.RequestCard.requestedby": "Requested by {username}", "components.RequestCard.seasons": "Seasons", - "components.RequestList.RequestItem.failedretry": "Something went wrong retrying the request", + "components.RequestList.RequestItem.failedretry": "Something went wrong while retrying the request.", "components.RequestList.RequestItem.notavailable": "N/A", "components.RequestList.RequestItem.requestedby": "Requested by {username}", "components.RequestList.RequestItem.seasons": "Seasons", @@ -117,7 +152,7 @@ "components.RequestList.mediaInfo": "Media Info", "components.RequestList.modifiedBy": "Last Modified By", "components.RequestList.next": "Next", - "components.RequestList.noresults": "No Results.", + "components.RequestList.noresults": "No results.", "components.RequestList.previous": "Previous", "components.RequestList.requestedAt": "Requested At", "components.RequestList.requests": "Requests", @@ -134,26 +169,35 @@ "components.RequestModal.AdvancedRequester.loadingprofiles": "Loading profiles…", "components.RequestModal.AdvancedRequester.qualityprofile": "Quality Profile", "components.RequestModal.AdvancedRequester.rootfolder": "Root Folder", - "components.RequestModal.autoapproval": "Auto Approval", + "components.RequestModal.SearchByNameModal.next": "Next", + "components.RequestModal.SearchByNameModal.nosummary": "No summary for this title was found.", + "components.RequestModal.SearchByNameModal.notvdbid": "Manual Match Required", + "components.RequestModal.SearchByNameModal.notvdbiddescription": "We couldn't automatically match your request. Please select the correct match from the list below:", + "components.RequestModal.autoapproval": "Automatic Approval", + "components.RequestModal.backbutton": "Back", "components.RequestModal.cancel": "Cancel Request", "components.RequestModal.cancelling": "Cancelling…", "components.RequestModal.cancelrequest": "This will remove your request. Are you sure you want to continue?", "components.RequestModal.close": "Close", - "components.RequestModal.errorediting": "Something went wrong editing the request.", + "components.RequestModal.errorediting": "Something went wrong while editing the request.", "components.RequestModal.extras": "Extras", + "components.RequestModal.next": "Next", "components.RequestModal.notrequested": "Not Requested", + "components.RequestModal.notvdbid": "No TVDB ID was found for this item on TMDb.", + "components.RequestModal.notvdbiddescription": "Either add the TVDB ID to TMDb and try again later, or select the correct match below:", "components.RequestModal.numberofepisodes": "# of Episodes", - "components.RequestModal.pending4krequest": "Pending request for {title} in 4K", - "components.RequestModal.pendingrequest": "Pending request for {title}", + "components.RequestModal.pending4krequest": "Pending Request for {title} in 4K", + "components.RequestModal.pendingrequest": "Pending Request for {title}", "components.RequestModal.request": "Request", "components.RequestModal.request4k": "Request 4K", - "components.RequestModal.request4kfrom": "There is currently a pending 4K request from {username}", + "components.RequestModal.request4kfrom": "There is currently a pending 4K request from {username}.", "components.RequestModal.request4ktitle": "Request {title} in 4K", - "components.RequestModal.requestCancel": "Request for {title} cancelled", + "components.RequestModal.requestCancel": "Request for {title} cancelled.", "components.RequestModal.requestSuccess": "{title} requested.", "components.RequestModal.requestadmin": "Your request will be immediately approved.", "components.RequestModal.requestcancelled": "Request cancelled.", "components.RequestModal.requestedited": "Request edited.", + "components.RequestModal.requesterror": "Something went wrong while submitting the request.", "components.RequestModal.requestfrom": "There is currently a pending request from {username}", "components.RequestModal.requesting": "Requesting…", "components.RequestModal.requestseasons": "Request {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}", @@ -164,58 +208,58 @@ "components.RequestModal.status": "Status", "components.Search.searchresults": "Search Results", "components.Settings.Notifications.NotificationsPushover.accessToken": "Access Token", - "components.Settings.Notifications.NotificationsPushover.agentenabled": "Agent Enabled", + "components.Settings.Notifications.NotificationsPushover.agentenabled": "Enable Agent", "components.Settings.Notifications.NotificationsPushover.notificationtypes": "Notification Types", "components.Settings.Notifications.NotificationsPushover.pushoversettingsfailed": "Pushover notification settings failed to save.", "components.Settings.Notifications.NotificationsPushover.pushoversettingssaved": "Pushover notification settings saved!", "components.Settings.Notifications.NotificationsPushover.save": "Save Changes", "components.Settings.Notifications.NotificationsPushover.saving": "Saving…", - "components.Settings.Notifications.NotificationsPushover.settinguppushover": "Setting up Pushover Notifications", + "components.Settings.Notifications.NotificationsPushover.settinguppushover": "Setting Up Pushover Notifications", "components.Settings.Notifications.NotificationsPushover.settinguppushoverDescription": "To setup Pushover, you need to register an application and get the access token. When setting up the application, you can use one of the icons in the public folder on GitHub. You also need the Pushover user token, which can be found on the start page when you log in.", "components.Settings.Notifications.NotificationsPushover.test": "Test", "components.Settings.Notifications.NotificationsPushover.testsent": "Test notification sent!", "components.Settings.Notifications.NotificationsPushover.userToken": "User Token", - "components.Settings.Notifications.NotificationsPushover.validationAccessTokenRequired": "You must provide an access token.", - "components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "You must provide a user token.", - "components.Settings.Notifications.NotificationsSlack.agentenabled": "Agent Enabled", + "components.Settings.Notifications.NotificationsPushover.validationAccessTokenRequired": "You must provide an access token", + "components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "You must provide a user token", + "components.Settings.Notifications.NotificationsSlack.agentenabled": "Enable Agent", "components.Settings.Notifications.NotificationsSlack.notificationtypes": "Notification Types", "components.Settings.Notifications.NotificationsSlack.save": "Save Changes", "components.Settings.Notifications.NotificationsSlack.saving": "Saving…", - "components.Settings.Notifications.NotificationsSlack.settingupslack": "Setting up Slack Notifications", + "components.Settings.Notifications.NotificationsSlack.settingupslack": "Setting Up Slack Notifications", "components.Settings.Notifications.NotificationsSlack.settingupslackDescription": "To use Slack notifications, you will need to create an Incoming Webhook integration and use the provided webhook URL below.", "components.Settings.Notifications.NotificationsSlack.slacksettingsfailed": "Slack notification settings failed to save.", "components.Settings.Notifications.NotificationsSlack.slacksettingssaved": "Slack notification settings saved!", "components.Settings.Notifications.NotificationsSlack.test": "Test", "components.Settings.Notifications.NotificationsSlack.testsent": "Test notification sent!", - "components.Settings.Notifications.NotificationsSlack.validationWebhookUrlRequired": "You must provide a webhook URL.", + "components.Settings.Notifications.NotificationsSlack.validationWebhookUrlRequired": "You must provide a webhook URL", "components.Settings.Notifications.NotificationsSlack.webhookUrl": "Webhook URL", "components.Settings.Notifications.NotificationsSlack.webhookUrlPlaceholder": "Webhook URL", - "components.Settings.Notifications.NotificationsWebhook.agentenabled": "Agent Enabled", + "components.Settings.Notifications.NotificationsWebhook.agentenabled": "Enable Agent", "components.Settings.Notifications.NotificationsWebhook.authheader": "Authorization Header", "components.Settings.Notifications.NotificationsWebhook.customJson": "Custom JSON Payload", "components.Settings.Notifications.NotificationsWebhook.notificationtypes": "Notification Types", - "components.Settings.Notifications.NotificationsWebhook.resetPayload": "Reset to Default JSON Payload", - "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON reset to default payload.", + "components.Settings.Notifications.NotificationsWebhook.resetPayload": "Reset to Default", + "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON payload successfully reset.", "components.Settings.Notifications.NotificationsWebhook.save": "Save Changes", "components.Settings.Notifications.NotificationsWebhook.saving": "Saving…", "components.Settings.Notifications.NotificationsWebhook.templatevariablehelp": "Template Variable Help", "components.Settings.Notifications.NotificationsWebhook.test": "Test", "components.Settings.Notifications.NotificationsWebhook.testsent": "Test notification sent!", - "components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "You must provide a JSON Payload.", - "components.Settings.Notifications.NotificationsWebhook.validationWebhookUrlRequired": "You must provide a webhook URL.", + "components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "You must provide a JSON payload", + "components.Settings.Notifications.NotificationsWebhook.validationWebhookUrlRequired": "You must provide a webhook URL", "components.Settings.Notifications.NotificationsWebhook.webhookUrl": "Webhook URL", "components.Settings.Notifications.NotificationsWebhook.webhookUrlPlaceholder": "Remote webhook URL", "components.Settings.Notifications.NotificationsWebhook.webhooksettingsfailed": "Webhook notification settings failed to save.", "components.Settings.Notifications.NotificationsWebhook.webhooksettingssaved": "Webhook notification settings saved!", - "components.Settings.Notifications.agentenabled": "Agent Enabled", + "components.Settings.Notifications.agentenabled": "Enable Agent", "components.Settings.Notifications.allowselfsigned": "Allow Self-Signed Certificates", "components.Settings.Notifications.authPass": "SMTP Password", "components.Settings.Notifications.authUser": "SMTP Username", "components.Settings.Notifications.botAPI": "Bot API", - "components.Settings.Notifications.chatId": "Chat Id", + "components.Settings.Notifications.chatId": "Chat ID", "components.Settings.Notifications.discordsettingsfailed": "Discord notification settings failed to save.", "components.Settings.Notifications.discordsettingssaved": "Discord notification settings saved!", - "components.Settings.Notifications.emailsender": "Email Sender Address", + "components.Settings.Notifications.emailsender": "Sender Address", "components.Settings.Notifications.emailsettingsfailed": "Email notification settings failed to save.", "components.Settings.Notifications.emailsettingssaved": "Email notification settings saved!", "components.Settings.Notifications.enableSsl": "Enable SSL", @@ -223,36 +267,39 @@ "components.Settings.Notifications.save": "Save Changes", "components.Settings.Notifications.saving": "Saving…", "components.Settings.Notifications.senderName": "Sender Name", - "components.Settings.Notifications.settinguptelegram": "Setting up Telegram Notifications", + "components.Settings.Notifications.settinguptelegram": "Setting Up Telegram Notifications", "components.Settings.Notifications.settinguptelegramDescription": "To setup Telegram, you need to create a bot and get the bot API key. Additionally, you need the chat ID for the chat you want the bot to send notifications to. You can do this by adding @get_id_bot to the chat or group chat.", "components.Settings.Notifications.smtpHost": "SMTP Host", "components.Settings.Notifications.smtpPort": "SMTP Port", - "components.Settings.Notifications.ssldisabletip": "SSL should be disabled on standard TLS connections (Port 587)", + "components.Settings.Notifications.ssldisabletip": "SSL should be disabled on standard TLS connections (port 587)", "components.Settings.Notifications.telegramsettingsfailed": "Telegram notification settings failed to save.", "components.Settings.Notifications.telegramsettingssaved": "Telegram notification settings saved!", "components.Settings.Notifications.test": "Test", "components.Settings.Notifications.testsent": "Test notification sent!", - "components.Settings.Notifications.validationBotAPIRequired": "You must provide a Bot API key.", - "components.Settings.Notifications.validationChatIdRequired": "You must provide a Chat ID.", - "components.Settings.Notifications.validationFromRequired": "You must provide an email sender address.", - "components.Settings.Notifications.validationSmtpHostRequired": "You must provide an SMTP host.", - "components.Settings.Notifications.validationSmtpPortRequired": "You must provide an SMTP port.", - "components.Settings.Notifications.validationWebhookUrlRequired": "You must provide a webhook URL.", + "components.Settings.Notifications.validationBotAPIRequired": "You must provide a Bot API key", + "components.Settings.Notifications.validationChatIdRequired": "You must provide a Chat ID", + "components.Settings.Notifications.validationFromRequired": "You must provide a sender address", + "components.Settings.Notifications.validationSmtpHostRequired": "You must provide an SMTP host", + "components.Settings.Notifications.validationSmtpPortRequired": "You must provide an SMTP port", + "components.Settings.Notifications.validationWebhookUrlRequired": "You must provide a webhook URL", "components.Settings.Notifications.webhookUrl": "Webhook URL", - "components.Settings.Notifications.webhookUrlPlaceholder": "Server Settings -> Integrations -> Webhooks", + "components.Settings.Notifications.webhookUrlPlaceholder": "Server Settings → Integrations → Webhooks", "components.Settings.RadarrModal.add": "Add Server", "components.Settings.RadarrModal.apiKey": "API Key", - "components.Settings.RadarrModal.apiKeyPlaceholder": "Your Radarr API Key", + "components.Settings.RadarrModal.apiKeyPlaceholder": "Your Radarr API key", "components.Settings.RadarrModal.baseUrl": "Base URL", "components.Settings.RadarrModal.baseUrlPlaceholder": "Example: /radarr", "components.Settings.RadarrModal.createradarr": "Create New Radarr Server", "components.Settings.RadarrModal.defaultserver": "Default Server", "components.Settings.RadarrModal.editradarr": "Edit Radarr Server", + "components.Settings.RadarrModal.externalUrl": "External URL", + "components.Settings.RadarrModal.externalUrlPlaceholder": "External URL pointing to your Radarr server", "components.Settings.RadarrModal.hostname": "Hostname", "components.Settings.RadarrModal.loadingprofiles": "Loading quality profiles…", "components.Settings.RadarrModal.loadingrootfolders": "Loading root folders…", "components.Settings.RadarrModal.minimumAvailability": "Minimum Availability", "components.Settings.RadarrModal.port": "Port", + "components.Settings.RadarrModal.preventSearch": "Disable Auto-Search", "components.Settings.RadarrModal.qualityprofile": "Quality Profile", "components.Settings.RadarrModal.rootfolder": "Root Folder", "components.Settings.RadarrModal.save": "Save Changes", @@ -264,33 +311,34 @@ "components.Settings.RadarrModal.servername": "Server Name", "components.Settings.RadarrModal.servernamePlaceholder": "A Radarr Server", "components.Settings.RadarrModal.ssl": "SSL", + "components.Settings.RadarrModal.syncEnabled": "Enable Sync", "components.Settings.RadarrModal.test": "Test", - "components.Settings.RadarrModal.testFirstQualityProfiles": "Test your connection to load quality profiles", - "components.Settings.RadarrModal.testFirstRootFolders": "Test your connection to load root folders", + "components.Settings.RadarrModal.testFirstQualityProfiles": "Test connection to load quality profiles", + "components.Settings.RadarrModal.testFirstRootFolders": "Test connection to load root folders", "components.Settings.RadarrModal.testing": "Testing…", - "components.Settings.RadarrModal.toastRadarrTestFailure": "Failed to connect to Radarr Server", + "components.Settings.RadarrModal.toastRadarrTestFailure": "Failed to connect to Radarr.", "components.Settings.RadarrModal.toastRadarrTestSuccess": "Radarr connection established!", - "components.Settings.RadarrModal.validationApiKeyRequired": "You must provide an API key.", - "components.Settings.RadarrModal.validationHostnameRequired": "You must provide a hostname/IP.", - "components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "You must select a minimum availability.", - "components.Settings.RadarrModal.validationNameRequired": "You must provide a server name.", - "components.Settings.RadarrModal.validationPortRequired": "You must provide a port.", - "components.Settings.RadarrModal.validationProfileRequired": "You must select a quality profile.", - "components.Settings.RadarrModal.validationRootFolderRequired": "You must select a root folder.", + "components.Settings.RadarrModal.validationApiKeyRequired": "You must provide an API key", + "components.Settings.RadarrModal.validationHostnameRequired": "You must provide a hostname/IP", + "components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "You must select a minimum availability", + "components.Settings.RadarrModal.validationNameRequired": "You must provide a server name", + "components.Settings.RadarrModal.validationPortRequired": "You must provide a port", + "components.Settings.RadarrModal.validationProfileRequired": "You must select a quality profile", + "components.Settings.RadarrModal.validationRootFolderRequired": "You must select a root folder", "components.Settings.SettingsAbout.Releases.currentversion": "Current Version", "components.Settings.SettingsAbout.Releases.latestversion": "Latest Version", - "components.Settings.SettingsAbout.Releases.releasedataMissing": "Release data missing. Is GitHub down?", + "components.Settings.SettingsAbout.Releases.releasedataMissing": "Release data unavailable. Is GitHub down?", "components.Settings.SettingsAbout.Releases.releases": "Releases", "components.Settings.SettingsAbout.Releases.runningDevelop": "You are running a develop version of Overseerr!", - "components.Settings.SettingsAbout.Releases.runningDevelopMessage": "The changes in your version will not be available below. Please look at the GitHub repository for latest updates.", + "components.Settings.SettingsAbout.Releases.runningDevelopMessage": "The changes in your version will not be available below. Please see the GitHub repository for latest updates.", "components.Settings.SettingsAbout.Releases.versionChangelog": "Version Changelog", "components.Settings.SettingsAbout.Releases.viewchangelog": "View Changelog", "components.Settings.SettingsAbout.Releases.viewongithub": "View on GitHub", - "components.Settings.SettingsAbout.clickheretojoindiscord": "Click here to join our Discord server.", + "components.Settings.SettingsAbout.clickheretojoindiscord": "Click here to join our Discord server!", "components.Settings.SettingsAbout.documentation": "Documentation", "components.Settings.SettingsAbout.gettingsupport": "Getting Support", "components.Settings.SettingsAbout.githubdiscussions": "GitHub Discussions", - "components.Settings.SettingsAbout.helppaycoffee": "Help pay for coffee", + "components.Settings.SettingsAbout.helppaycoffee": "Help Pay for Coffee", "components.Settings.SettingsAbout.overseerrinformation": "Overseerr Information", "components.Settings.SettingsAbout.supportoverseerr": "Support Overseerr", "components.Settings.SettingsAbout.timezone": "Timezone", @@ -301,16 +349,19 @@ "components.Settings.SonarrModal.animequalityprofile": "Anime Quality Profile", "components.Settings.SonarrModal.animerootfolder": "Anime Root Folder", "components.Settings.SonarrModal.apiKey": "API Key", - "components.Settings.SonarrModal.apiKeyPlaceholder": "Your Sonarr API Key", + "components.Settings.SonarrModal.apiKeyPlaceholder": "Your Sonarr API key", "components.Settings.SonarrModal.baseUrl": "Base URL", "components.Settings.SonarrModal.baseUrlPlaceholder": "Example: /sonarr", "components.Settings.SonarrModal.createsonarr": "Create New Sonarr Server", "components.Settings.SonarrModal.defaultserver": "Default Server", "components.Settings.SonarrModal.editsonarr": "Edit Sonarr Server", + "components.Settings.SonarrModal.externalUrl": "External URL", + "components.Settings.SonarrModal.externalUrlPlaceholder": "External URL pointing to your Sonarr server", "components.Settings.SonarrModal.hostname": "Hostname", "components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…", "components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…", "components.Settings.SonarrModal.port": "Port", + "components.Settings.SonarrModal.preventSearch": "Disable Auto-Search", "components.Settings.SonarrModal.qualityprofile": "Quality Profile", "components.Settings.SonarrModal.rootfolder": "Root Folder", "components.Settings.SonarrModal.save": "Save Changes", @@ -322,26 +373,31 @@ "components.Settings.SonarrModal.servername": "Server Name", "components.Settings.SonarrModal.servernamePlaceholder": "A Sonarr Server", "components.Settings.SonarrModal.ssl": "SSL", + "components.Settings.SonarrModal.syncEnabled": "Enable Sync", "components.Settings.SonarrModal.test": "Test", - "components.Settings.SonarrModal.testFirstQualityProfiles": "Test your connection to load quality profiles", - "components.Settings.SonarrModal.testFirstRootFolders": "Test your connection to load root folders", + "components.Settings.SonarrModal.testFirstQualityProfiles": "Test connection to load quality profiles", + "components.Settings.SonarrModal.testFirstRootFolders": "Test connection to load root folders", "components.Settings.SonarrModal.testing": "Testing…", - "components.Settings.SonarrModal.toastRadarrTestFailure": "Could not connect to Sonarr Server", - "components.Settings.SonarrModal.toastRadarrTestSuccess": "Sonarr connection established!", - "components.Settings.SonarrModal.validationApiKeyRequired": "You must provide an API key.", - "components.Settings.SonarrModal.validationHostnameRequired": "You must provide a hostname/IP.", - "components.Settings.SonarrModal.validationNameRequired": "You must provide a server name.", - "components.Settings.SonarrModal.validationPortRequired": "You must provide a port.", - "components.Settings.SonarrModal.validationProfileRequired": "You must select a quality profile.", - "components.Settings.SonarrModal.validationRootFolderRequired": "You must select a root folder.", + "components.Settings.SonarrModal.toastSonarrTestFailure": "Failed to connect to Sonarr.", + "components.Settings.SonarrModal.toastSonarrTestSuccess": "Sonarr connection established!", + "components.Settings.SonarrModal.validationApiKeyRequired": "You must provide an API key", + "components.Settings.SonarrModal.validationHostnameRequired": "You must provide a hostname/IP", + "components.Settings.SonarrModal.validationNameRequired": "You must provide a server name", + "components.Settings.SonarrModal.validationPortRequired": "You must provide a port", + "components.Settings.SonarrModal.validationProfileRequired": "You must select a quality profile", + "components.Settings.SonarrModal.validationRootFolderRequired": "You must select a root folder", "components.Settings.activeProfile": "Active Profile", "components.Settings.addradarr": "Add Radarr Server", "components.Settings.address": "Address", "components.Settings.addsonarr": "Add Sonarr Server", "components.Settings.apikey": "API Key", "components.Settings.applicationurl": "Application URL", + "components.Settings.autoapprovedrequests": "Send Notifications for Auto-Approved Requests", + "components.Settings.canceljob": "Cancel Job", "components.Settings.cancelscan": "Cancel Scan", - "components.Settings.copied": "Copied API key to clipboard", + "components.Settings.copied": "Copied API key to clipboard.", + "components.Settings.csrfProtection": "Enable CSRF Protection", + "components.Settings.csrfProtectionTip": "Sets external API access to read-only (Overseerr must be reloaded for changes to take effect)", "components.Settings.currentlibrary": "Current Library: {name}", "components.Settings.default": "Default", "components.Settings.default4k": "Default 4K", @@ -349,13 +405,18 @@ "components.Settings.delete": "Delete", "components.Settings.deleteserverconfirm": "Are you sure you want to delete this server?", "components.Settings.edit": "Edit", + "components.Settings.enablenotifications": "Enable Notifications", "components.Settings.generalsettings": "General Settings", - "components.Settings.generalsettingsDescription": "These are settings related to general Overseerr configuration.", + "components.Settings.generalsettingsDescription": "Configure global and default settings for Overseerr.", + "components.Settings.hideAvailable": "Hide Available Media", "components.Settings.hostname": "Hostname/IP", + "components.Settings.jobcancelled": "{jobname} cancelled.", "components.Settings.jobname": "Job Name", + "components.Settings.jobstarted": "{jobname} started.", + "components.Settings.jobtype": "Type", "components.Settings.librariesRemaining": "Libraries Remaining: {count}", "components.Settings.manualscan": "Manual Library Scan", - "components.Settings.manualscanDescription": "Normally, this will only be run once every 24 hours. Overseerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one time full manual library scan is recommended!", + "components.Settings.manualscanDescription": "Normally, this will only be run once every 24 hours. Overseerr will check your Plex server's recently added more aggressively. If this is your first time configuring Plex, a one-time full manual library scan is recommended!", "components.Settings.menuAbout": "About", "components.Settings.menuGeneralSettings": "General Settings", "components.Settings.menuJobs": "Jobs", @@ -363,47 +424,72 @@ "components.Settings.menuNotifications": "Notifications", "components.Settings.menuPlexSettings": "Plex", "components.Settings.menuServices": "Services", + "components.Settings.ms": "ms", "components.Settings.nextexecution": "Next Execution", "components.Settings.nodefault": "No default server selected!", "components.Settings.nodefaultdescription": "At least one server must be marked as default before any requests will make it to your services.", + "components.Settings.notificationAgentSettingsDescription": "Choose the types of notifications to send, and which notification agents to use.", + "components.Settings.notificationAgentsSettings": "Notification Agents", "components.Settings.notificationsettings": "Notification Settings", - "components.Settings.notificationsettingsDescription": "Here you can pick and choose what types of notifications to send and through what types of services.", + "components.Settings.notificationsettingsDescription": "Configure global notification settings. The options below will apply to all notification agents.", + "components.Settings.notificationsettingsfailed": "Notification settings failed to save.", + "components.Settings.notificationsettingssaved": "Notification settings saved!", "components.Settings.notrunning": "Not Running", "components.Settings.plexlibraries": "Plex Libraries", - "components.Settings.plexlibrariesDescription": "The libraries Overseerr scans for titles. Set up and save your Plex connection settings and click the button below if none are listed.", + "components.Settings.plexlibrariesDescription": "The libraries Overseerr scans for titles. Set up and save your Plex connection settings, then click the button below if no libraries are listed.", "components.Settings.plexsettings": "Plex Settings", - "components.Settings.plexsettingsDescription": "Configure the settings for your Plex server. Overseerr uses your Plex server to scan your library at an interval and see what content is available.", + "components.Settings.plexsettingsDescription": "Configure the settings for your Plex server. Overseerr scans your Plex libraries to see what content is available.", "components.Settings.port": "Port", - "components.Settings.radarrSettingsDescription": "Set up your Radarr connection below. You can have multiple, but only two active as defaults at any time (one for standard HD, and one for 4K). Administrators can override which server is used for new requests.", + "components.Settings.radarrSettingsDescription": "Set up your Radarr connection below. You can have multiple, but only two active as defaults at any time (one for standard HD, and one for 4K). Administrators can override the server is used for new requests.", "components.Settings.radarrsettings": "Radarr Settings", "components.Settings.runnow": "Run Now", "components.Settings.save": "Save Changes", "components.Settings.saving": "Saving…", - "components.Settings.servername": "Server Name (Automatically set after you save)", + "components.Settings.serverConnected": "connected", + "components.Settings.serverLocal": "local", + "components.Settings.serverRemote": "remote", + "components.Settings.servername": "Server Name", "components.Settings.servernamePlaceholder": "Plex Server Name", - "components.Settings.sonarrSettingsDescription": "Set up your Sonarr connection below. You can have multiple, but only two active as defaults at any time (one for standard HD and one for 4K). Administrators can override which server is used for new requests.", + "components.Settings.servernameTip": "Automatically retrieved from Plex after saving", + "components.Settings.serverpreset": "Server", + "components.Settings.serverpresetLoad": "Press the button to load available servers", + "components.Settings.serverpresetManualMessage": "Manually configure", + "components.Settings.serverpresetPlaceholder": "Plex Server", + "components.Settings.serverpresetRefreshing": "Retrieving servers…", + "components.Settings.settingUpPlex": "Setting Up Plex", + "components.Settings.settingUpPlexDescription": "To set up Plex, you can either enter your details manually or select a server retrieved from plex.tv. Press the button to the right of the dropdown to check connectivity and retrieve available servers.", + "components.Settings.sonarrSettingsDescription": "Set up your Sonarr connection below. You can have multiple, but only two active as defaults at any time (one for standard HD and one for 4K). Administrators can override the server is used for new requests.", "components.Settings.sonarrsettings": "Sonarr Settings", "components.Settings.ssl": "SSL", "components.Settings.startscan": "Start Scan", "components.Settings.sync": "Sync Plex Libraries", "components.Settings.syncing": "Syncing…", - "components.Settings.toastApiKeyFailure": "Something went wrong generating a new API Key.", + "components.Settings.timeout": "Timeout", + "components.Settings.toastApiKeyFailure": "Something went wrong while generating a new API key.", "components.Settings.toastApiKeySuccess": "New API key generated!", - "components.Settings.toastSettingsFailure": "Something went wrong saving settings.", + "components.Settings.toastPlexConnecting": "Attempting to connect to Plex…", + "components.Settings.toastPlexConnectingFailure": "Unable to connect to Plex!", + "components.Settings.toastPlexConnectingSuccess": "Connected to Plex server.", + "components.Settings.toastPlexRefresh": "Retrieving server list from Plex…", + "components.Settings.toastPlexRefreshFailure": "Unable to retrieve Plex server list!", + "components.Settings.toastPlexRefreshSuccess": "Retrieved Plex server list.", + "components.Settings.toastSettingsFailure": "Something went wrong while saving settings.", "components.Settings.toastSettingsSuccess": "Settings saved.", - "components.Settings.validationHostnameRequired": "You must provide a hostname/IP.", - "components.Settings.validationPortRequired": "You must provide a port.", + "components.Settings.trustProxy": "Enable Proxy Support", + "components.Settings.trustProxyTip": "Allows Overseerr to correctly register client IP addresses behind a proxy (Overseerr must be reloaded for changes to take effect)", + "components.Settings.validationHostnameRequired": "You must provide a hostname/IP", + "components.Settings.validationPortRequired": "You must provide a port", "components.Setup.configureplex": "Configure Plex", "components.Setup.configureservices": "Configure Services", "components.Setup.continue": "Continue", "components.Setup.finish": "Finish Setup", "components.Setup.finishing": "Finishing…", - "components.Setup.loginwithplex": "Login with Plex", + "components.Setup.loginwithplex": "Log in with Plex", "components.Setup.signinMessage": "Get started by logging in with your Plex account", "components.Setup.syncingbackground": "Syncing will run in the background. You can continue the setup process in the meantime.", "components.Setup.tip": "Tip", "components.Setup.welcome": "Welcome to Overseerr", - "components.Slider.noresults": "No Results", + "components.Slider.noresults": "No results.", "components.StatusBadge.status4k": "4K {status}", "components.StatusChacker.newversionDescription": "An update is now available. Click the button below to reload the application.", "components.StatusChacker.newversionavailable": "New Version Available", @@ -412,23 +498,32 @@ "components.TitleCard.tvshow": "Series", "components.TvDetails.TvCast.fullseriescast": "Full Series Cast", "components.TvDetails.TvCrew.fullseriescrew": "Full Series Crew", + "components.TvDetails.allseasonsmarkedavailable": "* All seasons will be marked as available.", "components.TvDetails.anime": "Anime", "components.TvDetails.approve": "Approve", + "components.TvDetails.areyousure": "Are you sure?", "components.TvDetails.available": "Available", "components.TvDetails.cancelrequest": "Cancel Request", "components.TvDetails.cast": "Cast", "components.TvDetails.decline": "Decline", + "components.TvDetails.downloadstatus": "Download Status", "components.TvDetails.firstAirDate": "First Air Date", "components.TvDetails.manageModalClearMedia": "Clear All Media Data", - "components.TvDetails.manageModalClearMediaWarning": "This will remove all media data including all requests for this item, irreversibly. If this item exists in your Plex library, the media info will be recreated next sync.", + "components.TvDetails.manageModalClearMediaWarning": "This will irreversibly remove all data for this TV series, including any requests. If this item exists in your Plex library, the media information will be recreated during the next sync.", "components.TvDetails.manageModalNoRequests": "No Requests", "components.TvDetails.manageModalRequests": "Requests", "components.TvDetails.manageModalTitle": "Manage Series", + "components.TvDetails.mark4kavailable": "Mark 4K as Available", + "components.TvDetails.markavailable": "Mark as Available", "components.TvDetails.network": "Network", + "components.TvDetails.opensonarr": "Open Series in Sonarr", + "components.TvDetails.opensonarr4k": "Open Series in 4K Sonarr", "components.TvDetails.originallanguage": "Original Language", "components.TvDetails.overview": "Overview", - "components.TvDetails.overviewunavailable": "Overview unavailable", + "components.TvDetails.overviewunavailable": "Overview unavailable.", "components.TvDetails.pending": "Pending", + "components.TvDetails.play4konplex": "Play 4K on Plex", + "components.TvDetails.playonplex": "Play on Plex", "components.TvDetails.recommendations": "Recommendations", "components.TvDetails.recommendationssubtext": "If you liked {title}, you might also like…", "components.TvDetails.showtype": "Show Type", @@ -439,43 +534,19 @@ "components.TvDetails.userrating": "User Rating", "components.TvDetails.viewfullcrew": "View Full Crew", "components.TvDetails.watchtrailer": "Watch Trailer", - "components.UserEdit.admin": "Admin", - "components.UserEdit.adminDescription": "Full administrator access. Bypasses all permission checks.", - "components.UserEdit.advancedrequest": "Advanced Requests", - "components.UserEdit.advancedrequestDescription": "Grants permission to use advanced request options. (Ex. Changing servers/profiles/paths)", - "components.UserEdit.autoapprove": "Auto Approve", - "components.UserEdit.autoapproveDescription": "Grants auto approval for any requests made by this user.", - "components.UserEdit.autoapproveMovies": "Auto Approve Movies", - "components.UserEdit.autoapproveMoviesDescription": "Grants auto approve for movie requests made by this user.", - "components.UserEdit.autoapproveSeries": "Auto Approve Series", - "components.UserEdit.autoapproveSeriesDescription": "Grants auto approve for series requests made by this user.", "components.UserEdit.avatar": "Avatar", "components.UserEdit.edituser": "Edit User", "components.UserEdit.email": "Email", - "components.UserEdit.managerequests": "Manage Requests", - "components.UserEdit.managerequestsDescription": "Grants permission to manage Overseerr requests. This includes approving and denying requests.", "components.UserEdit.permissions": "Permissions", - "components.UserEdit.request": "Request", - "components.UserEdit.request4k": "Request 4K", - "components.UserEdit.request4kDescription": "Grants permission to request 4K movies and series.", - "components.UserEdit.request4kMovies": "Request 4K Movies", - "components.UserEdit.request4kMoviesDescription": "Grants permission to request 4K movies.", - "components.UserEdit.request4kTv": "Request 4K Series", - "components.UserEdit.request4kTvDescription": "Grants permission to request 4K Series.", - "components.UserEdit.requestDescription": "Grants permission to request movies and series.", + "components.UserEdit.plexUsername": "Plex Username", "components.UserEdit.save": "Save", "components.UserEdit.saving": "Saving…", - "components.UserEdit.settings": "Manage Settings", - "components.UserEdit.settingsDescription": "Grants permission to modify all Overseerr settings. A user must have this permission to grant it to others.", - "components.UserEdit.userfail": "Something went wrong saving the user.", - "components.UserEdit.username": "Username", - "components.UserEdit.users": "Manage Users", - "components.UserEdit.usersDescription": "Grants permission to manage Overseerr users. Users with this permission cannot modify users with Administrator privilege, or grant it.", - "components.UserEdit.usersaved": "User saved", - "components.UserEdit.vote": "Vote", - "components.UserEdit.voteDescription": "Grants permission to vote on requests (voting not yet implemented)", + "components.UserEdit.userfail": "Something went wrong while saving the user.", + "components.UserEdit.username": "Display Name", + "components.UserEdit.usersaved": "User saved!", "components.UserList.admin": "Admin", "components.UserList.autogeneratepassword": "Automatically generate password", + "components.UserList.bulkedit": "Bulk Edit", "components.UserList.create": "Create", "components.UserList.created": "Created", "components.UserList.createlocaluser": "Create Local User", @@ -487,26 +558,27 @@ "components.UserList.edit": "Edit", "components.UserList.email": "Email Address", "components.UserList.importedfromplex": "{userCount, plural, =0 {No new users} one {# new user} other {# new users}} imported from Plex", - "components.UserList.importfromplex": "Import Users From Plex", - "components.UserList.importfromplexerror": "Something went wrong importing users from Plex", + "components.UserList.importfromplex": "Import Users from Plex", + "components.UserList.importfromplexerror": "Something went wrong while importing users from Plex.", "components.UserList.lastupdated": "Last Updated", "components.UserList.localuser": "Local User", "components.UserList.password": "Password", - "components.UserList.passwordinfo": "Password Info", - "components.UserList.passwordinfodescription": "Email notification settings need to be enabled and setup in order to use the auto generated passwords", + "components.UserList.passwordinfo": "Password Information", + "components.UserList.passwordinfodescription": "Email notifications need to be configured and enabled in order to automatically generate passwords.", "components.UserList.plexuser": "Plex User", "components.UserList.role": "Role", "components.UserList.totalrequests": "Total Requests", "components.UserList.user": "User", - "components.UserList.usercreatedfailed": "Something went wrong when trying to create the user", - "components.UserList.usercreatedsuccess": "Successfully created the user", - "components.UserList.userdeleted": "User deleted", - "components.UserList.userdeleteerror": "Something went wrong deleting the user", + "components.UserList.usercreatedfailed": "Something went wrong while creating the user.", + "components.UserList.usercreatedsuccess": "User created successfully!", + "components.UserList.userdeleted": "User deleted.", + "components.UserList.userdeleteerror": "Something went wrong while deleting the user.", "components.UserList.userlist": "User List", "components.UserList.username": "Username", + "components.UserList.userssaved": "Users saved!", "components.UserList.usertype": "User Type", - "components.UserList.validationemailrequired": "Must enter a valid email address.", - "components.UserList.validationpasswordminchars": "Password is too short - should be 8 chars minimum.", + "components.UserList.validationemailrequired": "Must enter a valid email address", + "components.UserList.validationpasswordminchars": "Password is too short; should be a minimum of 8 characters", "i18n.approve": "Approve", "i18n.approved": "Approved", "i18n.available": "Available", @@ -517,6 +589,7 @@ "i18n.delete": "Delete", "i18n.deleting": "Deleting…", "i18n.edit": "Edit", + "i18n.experimental": "Experimental", "i18n.failed": "Failed", "i18n.movies": "Movies", "i18n.partiallyavailable": "Partially Available", @@ -527,10 +600,10 @@ "i18n.retry": "Retry", "i18n.tvshows": "Series", "i18n.unavailable": "Unavailable", - "pages.internalServerError": "{statusCode} - Internal Server Error", + "pages.internalServerError": "{statusCode} - Internal server error", "pages.oops": "Oops", "pages.pageNotFound": "404 - Page Not Found", "pages.returnHome": "Return Home", - "pages.serviceUnavailable": "{statusCode} - Service Unavailable", + "pages.serviceUnavailable": "{statusCode} - Service unavailable", "pages.somethingWentWrong": "{statusCode} - Something went wrong" } diff --git a/src/i18n/locale/es.json b/src/i18n/locale/es.json index ff45c78b..c7722264 100644 --- a/src/i18n/locale/es.json +++ b/src/i18n/locale/es.json @@ -536,5 +536,17 @@ "components.Login.login": "Iniciar sesión", "components.Login.loggingin": "Iniciando sesión…", "components.Login.goback": "Volver", - "components.Login.email": "Dirección de correo electrónico" + "components.Login.email": "Dirección de correo electrónico", + "components.NotificationTypeSelector.mediadeclined": "Media Rechazada", + "components.RequestModal.autoapproval": "Aprobación Automática", + "components.NotificationTypeSelector.mediadeclinedDescription": "Envía una notificación cuando la solicitud es rechazada.", + "i18n.experimental": "Experimental", + "components.Settings.hideAvailable": "Ocultar los medios disponibles", + "components.Login.signingin": "Iniciando sesión…", + "components.Login.signin": "Iniciar Sesión", + "components.RequestModal.SearchByNameModal.next": "Siguiente", + "components.PlexLoginButton.signinwithplex": "Iniciar Sesión", + "components.PlexLoginButton.signingin": "Iniciando sesión…", + "components.Login.signinwithplex": "Inicia sesión con Plex", + "components.Login.signinheader": "Inicia sesión para continuar" } diff --git a/src/i18n/locale/fr.json b/src/i18n/locale/fr.json index 0d9dfbd2..b98938e4 100644 --- a/src/i18n/locale/fr.json +++ b/src/i18n/locale/fr.json @@ -16,7 +16,7 @@ "components.Layout.Sidebar.settings": "Paramètres", "components.Layout.Sidebar.users": "Utilisateurs", "components.Layout.UserDropdown.signout": "Se déconnecter", - "components.Layout.alphawarning": "Ce logiciel est en version ALPHA. Presque tout est succeptible de mal fonctionner ou d'être instable. Veuillez signaler tout problème sur le GitHub d'Overseerr !", + "components.Layout.alphawarning": "Ce logiciel est en version ALPHA. Certaines fonctionnalités risquent de ne pas fonctionner ou d'être instable. Veuillez signaler tout problème sur notre GitHub !", "components.Login.signinplex": "S'identifier pour continuer", "components.MovieDetails.approve": "Valider", "components.MovieDetails.available": "Disponible", @@ -25,13 +25,13 @@ "components.MovieDetails.cast": "Casting", "components.MovieDetails.decline": "Refuser", "components.MovieDetails.manageModalClearMedia": "Effacer toutes les données médias", - "components.MovieDetails.manageModalClearMediaWarning": "Toutes les donnés de médias vont être éffacées pour cet élément irréversiblement. Si cet élément existe dans votre bibliothèque Plex, les informations du média seront recrées à la prochaine synchronisation.", + "components.MovieDetails.manageModalClearMediaWarning": "Ceci effacera toutes les données sur ce film de manière irréversible, y compris les demandes. Si cet élément existe dans votre bibliothèque Plex, les informations du média seront recréées à la prochaine synchronisation.", "components.MovieDetails.manageModalNoRequests": "Aucune demande", "components.MovieDetails.manageModalRequests": "Demandes d'ajout", "components.MovieDetails.manageModalTitle": "Gérer les films", "components.MovieDetails.originallanguage": "Langue originale", "components.MovieDetails.overview": "Résumé", - "components.MovieDetails.overviewunavailable": "Résumé indisponible", + "components.MovieDetails.overviewunavailable": "Résumé indisponible.", "components.MovieDetails.pending": "En attente", "components.MovieDetails.recommendations": "Recommendations", "components.MovieDetails.recommendationssubtext": "Si vous avez aimé {title}, vous aimerez peut-être…", @@ -50,7 +50,7 @@ "components.PersonDetails.nobiography": "Aucune biographie disponible.", "components.PlexLoginButton.loading": "Chargement…", "components.PlexLoginButton.loggingin": "Connexion en cours…", - "components.PlexLoginButton.loginwithplex": "Se connecter avec Plex", + "components.PlexLoginButton.loginwithplex": "Connectez-vous avec Plex", "components.RequestBlock.seasons": "Saisons", "components.RequestCard.all": "Toutes", "components.RequestCard.requestedby": "Demandé par {username}", @@ -75,7 +75,7 @@ "components.RequestModal.numberofepisodes": "Nbr d'épisodes", "components.RequestModal.pendingrequest": "Demande en attente pour {title}", "components.RequestModal.request": "Demande d'ajout", - "components.RequestModal.requestCancel": "Demande pour {title} annulée", + "components.RequestModal.requestCancel": "Demande pour {title} annulée.", "components.RequestModal.requestSuccess": "{title} demandé.", "components.RequestModal.requestadmin": "Votre demande d'ajout va être validée immédiatement.", "components.RequestModal.requestfrom": "Une demande d'ajout de {username} est en attente", @@ -90,13 +90,13 @@ "components.Settings.Notifications.agentenabled": "Agent activé", "components.Settings.Notifications.authPass": "Mot de passe SMTP", "components.Settings.Notifications.authUser": "Nom d'utilisateur SMTP", - "components.Settings.Notifications.emailsender": "e-mail de l'expéditeur", + "components.Settings.Notifications.emailsender": "Adresse de l'expéditeur", "components.Settings.Notifications.enableSsl": "Activer le SSL", "components.Settings.Notifications.save": "Sauvegarder les changements", "components.Settings.Notifications.saving": "Sauvegarde en cours…", "components.Settings.Notifications.smtpHost": "Hôte SMTP", "components.Settings.Notifications.smtpPort": "Port SMTP", - "components.Settings.Notifications.validationFromRequired": "Vous devez fournir une adresse e-mail d'expéditeur.", + "components.Settings.Notifications.validationFromRequired": "Vous devez fournir une adresse d'expéditeur", "components.Settings.Notifications.validationSmtpHostRequired": "Vous devez fournir un hôte SSL.", "components.Settings.Notifications.validationSmtpPortRequired": "Vous devez fournir un port SMTP.", "components.Settings.Notifications.validationWebhookUrlRequired": "Vous devez fournir une URL de webhook.", @@ -193,7 +193,7 @@ "components.Settings.menuServices": "Services", "components.Settings.nextexecution": "Prochaine exécution", "components.Settings.notificationsettings": "Paramètres de notification", - "components.Settings.notificationsettingsDescription": "Ici vous pouvez choisir quel type de notifications envoyer et avec quel service.", + "components.Settings.notificationsettingsDescription": "Configuration globale de notification. Les paramètres ci-dessous affectent tous les agents de notification.", "components.Settings.notrunning": "Pas en exécution", "components.Settings.plexlibraries": "Bibliothèques Plex", "components.Settings.plexlibrariesDescription": "Les bibliothèques Overseerr recherche les titres. Configurez et enregistrez vos paramètres de connexion Plex et cliquez sur le bouton ci-dessous si aucun n'est répertorié.", @@ -205,7 +205,7 @@ "components.Settings.runnow": "Lancer maintenant", "components.Settings.save": "Sauvegarder les changements", "components.Settings.saving": "Enregistrement en cours…", - "components.Settings.servername": "Nom du serveur (automatiquement défini après enregistrement)", + "components.Settings.servername": "Nom du serveur (récupéré depuis Plex)", "components.Settings.servernamePlaceholder": "Nom de serveur Plex", "components.Settings.sonarrSettingsDescription": "Configurez votre connexion Sonarr ci-dessous. Vous pouvez avoir plusieurs, mais seulement deux actives par défaut à tout moment (une pour la HD standard et un pour la 4K). Les administrateurs peuvent remplacer le serveur utilisé pour les nouvelles demandes.", "components.Settings.sonarrsettings": "Paramètres Sonarr", @@ -269,7 +269,7 @@ "components.UserEdit.username": "Nom d'utilisateur", "components.UserEdit.users": "Gérer les utilisateurs", "components.UserEdit.usersDescription": "Donne la permission de gérer les utilisateurs d'Overseerr. Les utilisateurs avec cette permission ne peuvent pas modifier les utilisateurs avec un des privilèges Admin, ni en donner.", - "components.UserEdit.usersaved": "Utilisateur sauvegardé", + "components.UserEdit.usersaved": "Utilisateur sauvegardé !", "components.UserEdit.vote": "Vote", "components.UserEdit.voteDescription": "Donne la permission de voter pour les demandes d'ajouts (La fonction de vote n'est pas encore active)", "components.UserList.admin": "Admin", @@ -318,7 +318,7 @@ "components.Settings.SettingsAbout.overseerrinformation": "Informations sur Overseerr", "components.Settings.SettingsAbout.githubdiscussions": "Discussions GitHub", "components.Settings.SettingsAbout.gettingsupport": "Obtenir de l'aide", - "components.Settings.SettingsAbout.clickheretojoindiscord": "Cliquez ici pour rejoindre notre serveur Discord.", + "components.Settings.SettingsAbout.clickheretojoindiscord": "Cliquez ici pour rejoindre notre serveur Discord !", "components.Settings.RadarrModal.validationNameRequired": "Vous devez fournir un nom de serveur.", "components.Setup.tip": "Astuce", "components.Setup.syncingbackground": "La synchronisation s'exécutera en arrière-plan. Vous pouvez continuer le processus de configuration en attendant.", @@ -384,7 +384,7 @@ "components.CollectionDetails.requestcollection": "Demander une collection", "components.CollectionDetails.requestSuccess": "{title} demandé avec succès !", "components.CollectionDetails.request": "Demander", - "components.CollectionDetails.overviewunavailable": "Résumé indisponible", + "components.CollectionDetails.overviewunavailable": "Résumé indisponible.", "components.CollectionDetails.overview": "Résumé", "components.CollectionDetails.numberofmovies": "Nombre de films : {count}", "components.CollectionDetails.movies": "Films", @@ -406,8 +406,8 @@ "components.Settings.Notifications.NotificationsSlack.settingupslack": "Paramétrage des notifications Slack", "components.Settings.Notifications.NotificationsSlack.saving": "Enregistrement…", "components.Settings.Notifications.NotificationsSlack.save": "Enregistrer les changements", - "components.Settings.Notifications.NotificationsSlack.agentenabled": "Agent activé", - "components.RequestList.RequestItem.failedretry": "Une erreur s'est produite lors du renvoi de la demande d'ajout", + "components.Settings.Notifications.NotificationsSlack.agentenabled": "Activer l'agent", + "components.RequestList.RequestItem.failedretry": "Une erreur s'est produite lors du renvoi de la demande d'ajout.", "components.Settings.Notifications.validationChatIdRequired": "Vous devez fournir un identifiant de conversation.", "components.Settings.Notifications.botAPI": "API du bot", "components.Settings.Notifications.validationBotAPIRequired": "Vous devez fournir une clé API de bot.", @@ -432,19 +432,19 @@ "components.NotificationTypeSelector.mediaapprovedDescription": "Envoie une notification quand le média est validé.", "components.NotificationTypeSelector.mediaapproved": "Média validé", "i18n.request": "Demander", - "components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "Vous devez fournir un jeton utilisateur.", - "components.Settings.Notifications.NotificationsPushover.validationAccessTokenRequired": "Vous devez fournir un jeton d'accès.", + "components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "Vous devez fournir un jeton utilisateur", + "components.Settings.Notifications.NotificationsPushover.validationAccessTokenRequired": "Vous devez fournir un jeton d'accès", "components.Settings.Notifications.NotificationsPushover.userToken": "Jeton utilisateur", "components.Settings.Notifications.NotificationsPushover.testsent": "Notification de test envoyée !", "components.Settings.Notifications.NotificationsPushover.test": "Test", "components.Settings.Notifications.NotificationsPushover.settinguppushoverDescription": "Pour configurer Pushover, vous devez enregistrer une application et obtenir le jeton d'accès. Lors de la configuration de l'application, vous pouvez utiliser l'une des icônes du dossier public sur GitHub. Vous avez également besoin du jeton d'utilisateur Pushover qui se trouve sur la page de démarrage quand vous vous connectez.", - "components.Settings.Notifications.NotificationsPushover.settinguppushover": "Configuration des notifications pushover", + "components.Settings.Notifications.NotificationsPushover.settinguppushover": "Configuration des notifications Pushover", "components.Settings.Notifications.NotificationsPushover.saving": "Enregistrement…", "components.Settings.Notifications.NotificationsPushover.save": "Enregistrer les changements", "components.Settings.Notifications.NotificationsPushover.pushoversettingssaved": "Paramètres de notification pushover enregistrés !", "components.Settings.Notifications.NotificationsPushover.pushoversettingsfailed": "Les paramètres de notification pushover n'ont pas pu être enregistrés.", "components.Settings.Notifications.NotificationsPushover.notificationtypes": "Types de notification", - "components.Settings.Notifications.NotificationsPushover.agentenabled": "Agent activé", + "components.Settings.Notifications.NotificationsPushover.agentenabled": "Activer l'agent", "components.Settings.Notifications.NotificationsPushover.accessToken": "Jeton d'accès", "components.RequestList.sortModified": "Dernière modification", "components.RequestList.sortAdded": "Date de la demande", @@ -474,7 +474,7 @@ "components.Settings.Notifications.NotificationsWebhook.authheader": "En-tête d'autorisation", "components.Settings.Notifications.NotificationsWebhook.agentenabled": "Agent activé", "components.RequestModal.request4ktitle": "Demander {title} en 4K", - "components.RequestModal.request4kfrom": "Il y a actuellement une demande 4K en attente de {username}", + "components.RequestModal.request4kfrom": "Il y a actuellement une demande 4K en attente de {username}.", "components.RequestButton.request4k": "Demande d'ajout en 4K", "components.RequestModal.request4k": "Demande d'ajout en 4K", "components.RequestModal.pending4krequest": "Demande en attente pour {title} en 4K", @@ -496,7 +496,7 @@ "components.Settings.Notifications.NotificationsWebhook.resetPayload": "Réinitialiser les données utiles JSON par défaut", "components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "Vous devez entrer des données utiles JSON.", "components.UserList.validationpasswordminchars": "Le mot de passe est trop court ; doit contenir au moins 8 caractères.", - "components.UserList.usercreatedsuccess": "L'utilisateur a bien été créé", + "components.UserList.usercreatedsuccess": "L'utilisateur a bien été créé !", "components.UserList.usercreatedfailed": "Une erreur s'est produite lors de la tentative de création de l'utilisateur", "components.UserList.passwordinfo": "Infos sur le mot de passe", "components.UserList.password": "Mot de passe", @@ -506,17 +506,143 @@ "components.UserList.createlocaluser": "Créer un utilisateur local", "components.UserList.create": "Créer", "components.UserList.autogeneratepassword": "Générer automatiquement le mot de passe", - "components.UserList.passwordinfodescription": "Les paramètres de notification par courrier électronique doivent être activés et configurés pour pouvoir utiliser les mots de passe générés automatiquement", + "components.UserList.passwordinfodescription": "Les notifications par courrier électronique doivent être configurées et activées afin de pouvoir utiliser les mots de passe générés automatiquement.", "components.UserList.email": "Adresse électronique", - "components.UserList.validationemailrequired": "Vous devez entrer une adresse électronique valide.", + "components.UserList.validationemailrequired": "Vous devez entrer une adresse électronique valide", "components.Login.validationpasswordrequired": "Mot de passe requis", "components.Login.validationemailrequired": "Adresse e-mail non valide", - "components.Login.signinwithoverseerr": "Connectez-vous avec Overseerr", + "components.Login.signinwithoverseerr": "Utilisez votre compte Overseerr", "components.Login.password": "Mot de passe", - "components.Login.loginerror": "Une erreur s'est produite lors de la tentative de connexion", + "components.Login.loginerror": "Une erreur s'est produite lors de la tentative de connexion.", "components.Login.login": "Se connecter", "components.Login.loggingin": "Connexion…", "components.Login.goback": "Retour", "components.Login.email": "Adresse e-mail", - "components.MediaSlider.ShowMoreCard.seemore": "Voir plus" + "components.MediaSlider.ShowMoreCard.seemore": "Voir plus", + "i18n.edit": "Modifier", + "components.UserEdit.advancedrequestDescription": "Accorde l'autorisation d'utiliser les options de demandes avancées. (Ex. Modification des serveurs / profils / chemins)", + "components.UserEdit.advancedrequest": "Demandes avancées", + "components.RequestModal.requestedited": "Demande modifiée.", + "components.RequestModal.requestcancelled": "Demande annulée.", + "components.RequestModal.errorediting": "Une erreur s'est produite lors de la modification de la demande.", + "components.RequestModal.autoapproval": "Validation automatique", + "components.RequestModal.AdvancedRequester.rootfolder": "Dossier Racine", + "components.RequestModal.AdvancedRequester.qualityprofile": "Profil de qualité", + "components.RequestModal.AdvancedRequester.loadingprofiles": "Chargement des profils…", + "components.RequestModal.AdvancedRequester.loadingfolders": "Chargement des dossiers…", + "components.RequestModal.AdvancedRequester.destinationserver": "Serveur de destination", + "components.RequestModal.AdvancedRequester.default": "(Défaut)", + "components.RequestModal.AdvancedRequester.animenote": "* Cette série est un animé.", + "components.RequestModal.AdvancedRequester.advancedoptions": "Options avancées", + "components.RequestBlock.requestoverrides": "Contournements de demande", + "components.RequestBlock.server": "Serveur", + "components.RequestBlock.rootfolder": "Dossier racine", + "components.RequestBlock.profilechanged": "Profil Modifié", + "components.NotificationTypeSelector.mediadeclined": "Média refusé", + "components.NotificationTypeSelector.mediadeclinedDescription": "Envoie une notification lorsqu'une demande est refusée.", + "i18n.experimental": "Expérimentale", + "components.Settings.hideAvailable": "Masquer les médias disponibles", + "components.RequestModal.requesterror": "Il y a eu un problème lors de la demande d'ajout.", + "components.RequestModal.SearchByNameModal.notvdbiddescription": "Nous avons pas pu associer votre demande d'ajout automatiquement. Veuillez sélectioner l'association correcte de la liste ci-dessous :", + "components.RequestModal.SearchByNameModal.notvdbid": "Association manuelle requise", + "components.RequestModal.notvdbiddescription": "Ajouter l'ID TVDB sur TMDB et revenir plus tard, ou bien sélectionner l'association correcte ci-dessous :", + "components.RequestModal.notvdbid": "Aucun ID de TVDB n'a été trouvé sur TMDb pour ce titre.", + "components.RequestModal.backbutton": "Retour", + "components.RequestModal.SearchByNameModal.nosummary": "Aucun résumé trouvé pour ce titre.", + "components.RequestModal.SearchByNameModal.next": "Suivant", + "components.RequestModal.next": "Suivant", + "components.Login.signinwithplex": "Utilisez votre compte Plex", + "components.Login.signin": "Connexion", + "components.Login.signinheader": "Connectez-vous pour continuer", + "components.Login.signingin": "Connexion en cours…", + "components.Settings.notificationsettingssaved": "Paramètres de notification enregistrés !", + "components.Settings.notificationsettingsfailed": "Les paramètres de notification n'ont pas pu être enregistrés.", + "components.Settings.notificationAgentsSettings": "Agents de notification", + "components.Settings.notificationAgentSettingsDescription": "Ici vous pouvez choisir les types de notifications à envoyer et par quels types de services.", + "components.Settings.enablenotifications": "Activer les Notifications", + "components.Settings.autoapprovedrequests": "Envoyer des notifications pour les demandes approuvées automatiquement", + "components.PlexLoginButton.signinwithplex": "Connectez-vous", + "components.PlexLoginButton.signingin": "Connexion en cours…", + "components.UserList.userssaved": "Utilisateurs enregistrés !", + "components.UserList.bulkedit": "Modification en masse", + "components.Settings.csrfProtectionTip": "Définit l'accès à l'API externe en lecture seule (Overseerr doit être rechargé pour que les modifications prennent effet)", + "components.Settings.csrfProtection": "Activer la protection CSRF", + "components.PermissionEdit.voteDescription": "Accorde l'autorisation de voter dans les demandes (vote pas encore implémenté)", + "components.PermissionEdit.vote": "Vote", + "components.PermissionEdit.usersDescription": "Accorde l'autorisation de gérer les utilisateurs d'Overseerr. Les utilisateurs disposant de cette autorisation ne peuvent pas modifier les utilisateurs dotés de privilèges d'administrateur ni les accorder.", + "components.PermissionEdit.users": "Gérer les utilisateurs", + "components.PermissionEdit.settingsDescription": "Accorde l'autorisation de modifier tous les paramètres d'Overseerr. Un utilisateur doit avoir cette autorisation pour l'accorder à d'autres.", + "components.PermissionEdit.settings": "Gérer les paramètres", + "components.PermissionEdit.requestDescription": "Accorde l'autorisation de demander des films et des séries.", + "components.PermissionEdit.request4kTvDescription": "Accorde l'autorisation de demander des séries 4K.", + "components.PermissionEdit.request4kTv": "Demande de séries 4K", + "components.PermissionEdit.request4kMoviesDescription": "Accorde l'autorisation de demander des films 4K.", + "components.PermissionEdit.request4kMovies": "Demande de films 4K", + "components.PermissionEdit.request4kDescription": "Accorde l'autorisation de demander des films et des séries 4K.", + "components.PermissionEdit.request4k": "Demande 4K", + "components.PermissionEdit.request": "Demande", + "components.PermissionEdit.managerequestsDescription": "Accorde l'autorisation de gérer les demandes d'Overseerr. Ceci inclut la validation et le refus des demandes.", + "components.PermissionEdit.managerequests": "Gérer les demandes", + "components.PermissionEdit.autoapproveSeriesDescription": "Accorde la validation automatique pour toutes les demandes de série faites par cet utilisateur.", + "components.PermissionEdit.autoapproveSeries": "Validation automatique des séries", + "components.PermissionEdit.autoapproveMoviesDescription": "Accorde la validation automatique des demandes de films faites par cet utilisateur.", + "components.PermissionEdit.autoapproveMovies": "Validation automatique des films", + "components.PermissionEdit.autoapproveDescription": "Accorde la validation automatique pour toutes les demandes faites par cet utilisateur.", + "components.PermissionEdit.autoapprove": "Validation automatique", + "components.PermissionEdit.advancedrequestDescription": "Accorde l'autorisation d'utiliser les options de demande avancées. (Ex. Modification des serveurs / profils / chemins).", + "components.PermissionEdit.advancedrequest": "Demandes avancées", + "components.PermissionEdit.adminDescription": "Accès administrateur complet. Contourne toutes les vérifications d'autorisation.", + "components.PermissionEdit.admin": "Admin", + "components.Settings.toastPlexRefreshSuccess": "Liste des serveurs récupérée depuis Plex", + "components.Settings.toastPlexRefreshFailure": "Impossible de récupérer la liste des serveurs Plex !", + "components.Settings.toastPlexRefresh": "Récupération de la liste des serveurs depuis Plex…", + "components.Settings.toastPlexConnectingSuccess": "Connecté au serveur Plex", + "components.Settings.toastPlexConnectingFailure": "Impossible de se connecter à Plex !", + "components.Settings.toastPlexConnecting": "Tentative de connexion à Plex…", + "components.Settings.timeout": "Délai d'expiration", + "components.Settings.settingUpPlexDescription": "Pour configurer Plex, vous pouvez entrer vos coordonnées manuellement ou choisir parmi l'un de vos serveurs disponibles récupérés sur plex.tv. Appuyez sur le bouton à côté de la liste déroulante pour actualiser la liste et revérifier la connectivité du serveur.", + "components.Settings.settingUpPlex": "Configuration de Plex", + "components.Settings.serverpresetRefreshing": "Récupération des serveurs…", + "components.Settings.serverpresetPlaceholder": "Serveur Plex (récupéré automatiquement)", + "components.Settings.serverpresetManualMessage": "Configurer manuellement", + "components.Settings.serverpresetLoad": "Appuyez sur le bouton pour charger les serveurs disponibles", + "components.Settings.serverpreset": "Serveur disponible", + "components.Settings.serverRemote": "distant", + "components.Settings.serverLocal": "local", + "components.Settings.serverConnected": "connecté", + "components.Settings.ms": "ms", + "components.Settings.RadarrModal.externalUrlPlaceholder": "L'URL externe pointant vers votre serveur Radarr", + "components.UserEdit.plexUsername": "Nom d'utilisateur Plex", + "components.TvDetails.playonplex": "Lire sur Plex", + "components.TvDetails.play4konplex": "Lire en 4K sur Plex", + "components.TvDetails.opensonarr4k": "Ouvrir la série dans Sonarr 4K", + "components.TvDetails.opensonarr": "Ouvrir la série dans Sonarr", + "components.TvDetails.markavailable": "Marquer comme disponible", + "components.TvDetails.mark4kavailable": "Marquer 4K comme disponible", + "components.TvDetails.downloadstatus": "État du téléchargement", + "components.TvDetails.areyousure": "Êtes-vous sûr·e ?", + "components.TvDetails.allseasonsmarkedavailable": "* Toutes les saisons seront marquées comme disponibles.", + "components.Settings.servernameTip": "Récupéré automatiquement de Plex après l'enregistrement", + "components.Settings.jobtype": "Type", + "components.Settings.jobstarted": "{jobname} commencé.", + "components.Settings.jobcancelled": "{jobname} annulé.", + "components.Settings.canceljob": "Annuler la tâche", + "components.Settings.SonarrModal.toastSonarrTestSuccess": "Connexion à Sonarr établie !", + "components.Settings.SonarrModal.toastSonarrTestFailure": "Échec de la connexion à Sonarr.", + "components.Settings.SonarrModal.syncEnabled": "Activer la synchronisation", + "components.Settings.SonarrModal.preventSearch": "Désactiver la recherche automatique", + "components.Settings.SonarrModal.externalUrlPlaceholder": "L'URL externe pointant sur votre serveur Sonarr", + "components.Settings.SonarrModal.externalUrl": "URL externe", + "components.Settings.RadarrModal.syncEnabled": "Activer la synchronisation", + "components.Settings.RadarrModal.preventSearch": "Désactiver la recherche automatique", + "components.Settings.RadarrModal.externalUrl": "URL externe", + "components.MovieDetails.markavailable": "Marquer comme disponible", + "components.MovieDetails.mark4kavailable": "Marquer 4K comme disponible", + "components.MovieDetails.playonplex": "Lire sur Plex", + "components.MovieDetails.play4konplex": "Lire en 4K sur Plex", + "components.MovieDetails.openradarr4k": "Ouvrir le film dans Radarr 4K", + "components.MovieDetails.openradarr": "Ouvrir le film dans Radarr", + "components.MovieDetails.downloadstatus": "État du téléchargement", + "components.MovieDetails.areyousure": "Êtes-vous sûr·e ?", + "components.Common.ListView.noresults": "Aucun résultat." } diff --git a/src/i18n/locale/hu.json b/src/i18n/locale/hu.json new file mode 100644 index 00000000..01eda0b1 --- /dev/null +++ b/src/i18n/locale/hu.json @@ -0,0 +1,175 @@ +{ + "components.RequestModal.AdvancedRequester.default": "(Alapértelmezett)", + "components.RequestModal.AdvancedRequester.animenote": "* Ez a sorozat egy anime.", + "components.RequestModal.AdvancedRequester.advancedoptions": "További beállítások", + "components.RequestList.status": "Állapot", + "components.RequestList.sortModified": "Utoljára módosítva", + "components.RequestList.sortAdded": "Kérés dátuma", + "components.RequestList.showallrequests": "Összes kérés mutatása", + "components.RequestList.requests": "Kérések", + "components.RequestList.requestedAt": "Kérés ideje", + "components.RequestList.previous": "Előző", + "components.RequestList.noresults": "Nincs találat.", + "components.RequestList.next": "Következő", + "components.RequestList.modifiedBy": "Utoljára módosította", + "components.RequestList.mediaInfo": "Média információ", + "components.RequestList.filterPending": "Függőben lévő", + "components.RequestList.filterApproved": "Jóváhagyva", + "components.RequestList.filterAll": "Mind", + "components.RequestList.RequestItem.seasons": "Évadok", + "components.RequestList.RequestItem.requestedby": "Kérve {username} által", + "components.RequestList.RequestItem.failedretry": "Hiba történt a kérés újrapróbálása közben.", + "components.RequestCard.seasons": "Évadok", + "components.RequestCard.requestedby": "Kérve {username} által", + "components.RequestCard.all": "Mind", + "components.RequestButton.viewrequest4k": "4K kérés megtekintése", + "components.RequestButton.viewrequest": "Kérés megtekintése", + "components.RequestButton.requestmore4k": "Továbbiak 4K kérése", + "components.RequestButton.requestmore": "Továbbiak kérése", + "components.RequestButton.request4k": "4K kérés", + "components.RequestButton.request": "Kérés", + "components.RequestButton.declinerequest4k": "4K kérés elutasítása", + "components.RequestButton.declinerequest": "Kérés elutasítása", + "components.RequestButton.approverequest4k": "4K kérés jóváhagyása", + "components.RequestButton.approverequest": "Kérés jóváhagyása", + "components.RequestBlock.server": "Szerver", + "components.RequestBlock.seasons": "Évadok", + "components.RequestBlock.rootfolder": "Root könyvtár", + "components.RequestBlock.requestoverrides": "Kérés felülbírálások", + "components.PlexLoginButton.signinwithplex": "Bejelentkezés", + "components.PlexLoginButton.signingin": "Bejelentkezés folyamatban…", + "components.PlexLoginButton.loading": "Betöltés…", + "components.PersonDetails.nobiography": "Életrajz nem elérhető.", + "components.PersonDetails.crewmember": "Stáb tag", + "components.PersonDetails.ascharacter": "mint {character}", + "components.PersonDetails.appearsin": "Szerepel a következőkben", + "components.PermissionEdit.voteDescription": "Engedélyt ad a kérelmek szavazására. (A szavazás funkció még nincs implementálva)", + "components.PermissionEdit.vote": "Szavazás", + "components.PermissionEdit.usersDescription": "Engedélyt ad az Overseerr felhasználók kezelésére. Az ezzel az engedéllyel rendelkező felhasználók nem módosíthatják a rendszergazdai jogosultsággal rendelkező felhasználókat, és nem adhatják meg a jogosultságot más felhasználónak.", + "components.PermissionEdit.users": "Felhasználók kezelése", + "components.PermissionEdit.settingsDescription": "Engedélyt ad az Overseerr összes beállításának módosítására. A felhasználónak rendelkeznie kell ezzel az engedéllyel ahhoz, hogy másoknak megadja.", + "components.PermissionEdit.settings": "Beállítások kezelése", + "components.PermissionEdit.requestDescription": "Engedélyt ad filmek és sorozatok kérésére.", + "components.PermissionEdit.request4kTvDescription": "Engedélyt ad 4K-s sorozatok kérésére.", + "components.PermissionEdit.request4kTv": "Kérés - 4K sorozatok", + "components.PermissionEdit.request4kMoviesDescription": "Engedélyt ad 4K-s filmek kérésére.", + "components.PermissionEdit.request4kMovies": "Kérés - 4K filmek", + "components.PermissionEdit.request4kDescription": "Engedélyt ad 4K-s filmek és sorozatok kérésére.", + "components.PermissionEdit.request4k": "Kérés - 4K", + "components.PermissionEdit.request": "Kérés", + "components.PermissionEdit.managerequestsDescription": "Engedélyt ad az Overseerr kérések kezelésére. Ez magában foglalja a kérelmek jóváhagyását és elutasítását.", + "components.PermissionEdit.managerequests": "Kérések kezelése", + "components.PermissionEdit.autoapproveMoviesDescription": "Automatikusan jóváhagyja a felhasználó minden film kérését.", + "components.PermissionEdit.autoapproveSeriesDescription": "Automatikusan jóváhagyja a felhasználó minden sorozat kérését.", + "components.PermissionEdit.autoapproveSeries": "Automatikus jóváhagyás sorozatokhoz", + "components.PermissionEdit.autoapproveMovies": "Automatikus jóváhagyás filmekhez", + "components.PermissionEdit.autoapproveDescription": "Automatikusan jóváhagyja a felhasználó minden kérését.", + "components.PermissionEdit.autoapprove": "Automatikus jóváhagyás", + "components.PermissionEdit.advancedrequestDescription": "Engedélyt ad a speciális kérési opciók használatára; pl. szerverek, profilok, útvonalak megváltoztatása.", + "components.PermissionEdit.advancedrequest": "Speciális kérések", + "components.PermissionEdit.adminDescription": "Teljes rendszergazdai hozzáférés. Megkerüli az összes engedélyellenőrzést.", + "components.PermissionEdit.admin": "Admin", + "components.MovieDetails.watchtrailer": "Előzetes megtekintése", + "components.MovieDetails.viewfullcrew": "Teljes stáblista megtekintése", + "components.MovieDetails.view": "Nézet", + "components.MovieDetails.userrating": "Felhasználói értékelés", + "components.MovieDetails.unavailable": "Nem elérhető", + "components.MovieDetails.studio": "Stúdió", + "components.MovieDetails.status": "Állapot", + "components.MovieDetails.similar": "Hasonló tartalmak", + "components.MovieDetails.runtime": "{minutes} perc", + "components.MovieDetails.revenue": "Bevétel", + "components.MovieDetails.releasedate": "Megjelenés dátuma", + "components.MovieDetails.manageModalClearMediaWarning": "Ezzel véglegesen törlődik az ehhez az elemhez tartozó összes média adat, beleértve a hozzá kapcsolódó kéréseket. Ha ez az elem létezik a Plex könyvtárban, a média adatok újra létrehozásra kerülnek a következő szinkronizálásnál.", + "components.MovieDetails.recommendations": "Ajánlások", + "components.MovieDetails.pending": "Függőben lévő", + "components.MovieDetails.overviewunavailable": "Áttekintés nem elérhető", + "components.MovieDetails.overview": "Áttekintés", + "components.MovieDetails.originallanguage": "Eredeti nyelv", + "components.MovieDetails.manageModalTitle": "Film kezelése", + "components.MovieDetails.manageModalRequests": "Kérések", + "components.MovieDetails.manageModalNoRequests": "Nincsenek kérések", + "components.MovieDetails.manageModalClearMedia": "Összes média adat törlése", + "components.MovieDetails.decline": "Elutasítás", + "components.MovieDetails.cast": "Szereposztás", + "components.MovieDetails.cancelrequest": "Kérés visszavonása", + "components.MovieDetails.budget": "Költségvetés", + "components.MovieDetails.available": "Elérhető", + "components.MovieDetails.approve": "Jóváhagyás", + "components.MovieDetails.MovieCrew.fullcrew": "Teljes stáb", + "components.MovieDetails.MovieCast.fullcast": "Teljes szereposztás", + "components.MediaSlider.ShowMoreCard.seemore": "Még több", + "components.Login.validationpasswordrequired": "Jelszó szükséges", + "components.Login.validationemailrequired": "Nem érvényes e-mail cím", + "components.Login.signinwithplex": "Bejelentkezés Plex fiókkal", + "components.Login.signinwithoverseerr": "Bejelentkezés Overseerr fiókkal", + "components.Login.signinheader": "Jelentkezz be a folytatáshoz", + "components.Login.signingin": "Bejelentkezés folyamatban…", + "components.Login.signin": "Bejelentkezés", + "components.Login.password": "Jelszó", + "components.Login.loginerror": "Valami nem sikerült a bejelentkezés során.", + "components.Login.email": "Email cím", + "components.Layout.alphawarning": "Ez egy ALPHA szoftver. Előfordulhat, hogy egy-egy funkció hibásan működik vagy instabil. Kérlek jelentsd a problémákat GitHub-on!", + "components.Layout.UserDropdown.signout": "Kijelentkezés", + "components.Layout.Sidebar.users": "Felhasználók", + "components.Layout.Sidebar.settings": "Beállítások", + "components.Layout.Sidebar.requests": "Kérések", + "components.Layout.SearchInput.searchPlaceholder": "Filmek és sorozatok keresése", + "components.Layout.LanguagePicker.changelanguage": "Válts nyelvet", + "components.Discover.upcomingmovies": "Hamarosan megjelenő filmek", + "components.Discover.upcoming": "Hamarosan megjelenő filmek", + "components.Discover.trending": "Felkapott", + "components.Discover.recentrequests": "Legutóbbi kérések", + "components.Discover.recentlyAdded": "Nemrég hozzáadva", + "components.Discover.populartv": "Népszerű sorozatok", + "components.Discover.popularmovies": "Népszerű filmek", + "components.Discover.nopending": "Nincs függőben lévő kérés", + "components.Discover.discovertv": "Népszerű sorozatok", + "components.Discover.discovermovies": "Népszerű filmek", + "components.CollectionDetails.requesting": "Kérés feldolgozása…", + "components.CollectionDetails.requestcollection": "Gyűjtemény kérése", + "components.CollectionDetails.requestSuccess": "{title} kérése elküldve!", + "components.CollectionDetails.request": "Kérés", + "components.CollectionDetails.overviewunavailable": "Áttekintés nem elérhető.", + "components.CollectionDetails.overview": "Áttekintés", + "components.CollectionDetails.numberofmovies": "Filmek száma: {count}", + "components.CollectionDetails.movies": "Filmek", + "components.Search.searchresults": "Keresési találatok", + "components.RequestModal.status": "Állapot", + "components.RequestModal.selectseason": "Válassz évado(ka)t", + "components.RequestModal.seasonnumber": "{number}. évad", + "components.RequestModal.season": "Évad", + "components.RequestModal.requesting": "Kérés folyamatban…", + "components.RequestModal.requesterror": "Hiba történt a kérés beküldése közben.", + "components.RequestModal.requestedited": "Kérés szerkesztve.", + "components.RequestModal.requestcancelled": "Kérés visszavonva.", + "components.RequestModal.requestadmin": "A kérésed azonnal el lesz fogadva.", + "components.RequestModal.request4k": "Kérés - 4K", + "components.RequestModal.numberofepisodes": "Epizódok száma", + "components.RequestModal.notvdbiddescription": "Vagy add hozzá a TVDB azonosítót a TMDb-hez, és később próbálkozz újra, vagy válaszd ki a megfelelő egyezést alább:", + "components.RequestModal.notvdbid": "Nem található TVDB azonosító ehhez a címhez a TMDb-n.", + "components.RequestModal.notrequested": "Nincs kérve", + "components.RequestModal.next": "Következő", + "components.RequestModal.extras": "Extrák", + "components.RequestModal.errorediting": "Hiba történt a kérés szerkesztése közben.", + "components.RequestModal.close": "Bezár", + "components.RequestModal.cancelrequest": "Ezzel törlődni fog a kérésed. Biztosan folytatod?", + "components.RequestModal.cancelling": "Visszavonás folyamatban…", + "components.RequestModal.cancel": "Kérés visszavonása", + "components.RequestModal.backbutton": "Vissza", + "components.RequestModal.SearchByNameModal.nosummary": "Nem található összefoglaló ehhez a címhez.", + "components.RequestModal.SearchByNameModal.next": "Következő", + "components.RequestModal.AdvancedRequester.rootfolder": "Root könyvtár", + "components.RequestModal.AdvancedRequester.qualityprofile": "Minőség profil", + "components.RequestModal.AdvancedRequester.loadingprofiles": "Profilok betöltése…", + "components.RequestModal.AdvancedRequester.loadingfolders": "Könyvtárak betöltése…", + "components.RequestModal.AdvancedRequester.destinationserver": "Cél szerver", + "components.MovieDetails.playonplex": "Lejátszás Plex-en", + "components.MovieDetails.play4konplex": "4K lejátszás Plex-en", + "components.MovieDetails.openradarr4k": "Film megnyitása 4K Radarr-ban", + "components.MovieDetails.openradarr": "Film megnyitása Radarr-ban", + "components.MovieDetails.downloadstatus": "Letöltés állapota", + "components.MovieDetails.areyousure": "Biztos vagy benne?", + "components.Common.ListView.noresults": "Nincs találat.", + "components.RequestModal.request": "Kérés" +} diff --git a/src/i18n/locale/it.json b/src/i18n/locale/it.json index bd86c697..44c1acf9 100644 --- a/src/i18n/locale/it.json +++ b/src/i18n/locale/it.json @@ -192,7 +192,7 @@ "components.Settings.radarrsettings": "Impostazioni Radarr", "components.Settings.plexsettings": "Impostazioni Plex", "components.Settings.notrunning": "Non in esecuzione", - "components.Settings.notificationsettingsDescription": "Qui puoi scegliere quali tipi di notifiche inviare e attraverso quali tipi di servizi.", + "components.Settings.notificationsettingsDescription": "Configurazione delle notifiche globali. Le impostazioni seguenti hanno effetto su tutti gli agenti di notifica.", "components.Settings.notificationsettings": "Impostazioni di notifica", "components.Settings.nodefaultdescription": "Almeno un server deve essere contrassegnato come predefinito prima che qualsiasi richiesta lo apportare ai servizi.", "components.Settings.nodefault": "Nessun server predefinito selezionato!", @@ -329,13 +329,13 @@ "components.Settings.sync": "Sincronizza le librerie di Plex", "components.Settings.sonarrsettings": "Impostazioni Sonarr", "components.Settings.sonarrSettingsDescription": "Configura Sonarr qui sotto. È possibile avere più istanze, ma solo due predefinite contemporaneamente (uno per l'HD standard e uno per 4K). Gli amministratori possono ignorare quale server è utilizzato per le nuove richieste.", - "components.Settings.radarrSettingsDescription": "Configura Radarr qui sotto. È possibile avere più istanze, ma solo due predefinite contemporaneamente (uno per l'HD standard e uno per 4K). Gli amministratori possono ignorare quale server è utilizzato per le nuove richieste.", + "components.Settings.radarrSettingsDescription": "Configura Radarr qui sotto. È possibile avere più istanze, ma solo due predefinite contemporaneamente (uno per l'alta definizione standard e uno per 4K). Gli amministratori possono ignorare quale server è utilizzato per le nuove richieste.", "components.Settings.plexsettingsDescription": "Configura le impostazioni del tuo server Plex. Overseerr scansiona la tua libreria Plex a intervalli regolari alla ricerca di nuovi contenuti.", "components.Settings.plexlibrariesDescription": "Le librerie che verranno scansionate da Overseerr alla ricerca di titoli. Se non ci sono librerie, configura e salva le impostazioni di connessione a Plex e fai click sul pulsante qui sotto.", "components.Settings.plexlibraries": "Librerie Plex", "components.Settings.manualscanDescription": "Normalmente, questo verrà eseguito ogni 24 ore. Overseerr controllerà in modo più aggressivo i server Plex aggiunti di recente. Se è la prima volta che configuri Plex, si consiglia una scansione manuale completa della libreria!", "components.Settings.port": "Porta", - "components.Settings.servername": "Nome server (impostato automaticamente dopo il salvataggio)", + "components.Settings.servername": "Nome server (recuperato da Plex)", "components.Settings.servernamePlaceholder": "Nome server Plex", "components.Settings.toastSettingsFailure": "Qualcosa è andato storto durante il salvataggio delle impostazioni.", "components.Settings.toastApiKeySuccess": "Nuova chiave API generata!", @@ -453,13 +453,13 @@ "components.RequestList.filterPending": "In sospeso", "components.RequestList.filterApproved": "Approvate", "components.RequestList.filterAll": "Tutte", - "components.RequestButton.declinerequest4k": "Rifiuta Richiesta 4K", - "components.RequestButton.declinerequest": "Rifiuta Richiesta", - "components.RequestButton.decline4krequests": "Rifiuta {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}", - "components.RequestButton.approverequests": "Approva {requestCount} {requestCount, plural, one {Request} other {Requests}}", + "components.RequestButton.declinerequest4k": "Rifiuta la richiesta 4K", + "components.RequestButton.declinerequest": "Rifiuta la richiesta", + "components.RequestButton.decline4krequests": "Rifiuta {requestCount} 4K {requestCount, plural, one {richiesta} other {richieste}}", + "components.RequestButton.approverequests": "Approva {requestCount} {requestCount, plural, one {richiesta} other {richieste}}", "components.RequestButton.approverequest4k": "Approva richiesta 4K", - "components.RequestButton.approverequest": "Approva Richiesta", - "components.RequestButton.approve4krequests": "Approva {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}", + "components.RequestButton.approverequest": "Approva la richiesta", + "components.RequestButton.approve4krequests": "Approva {requestCount} 4K {requestCount, plural, one {richiesta} other {richieste}}", "components.UserList.creating": "Creazione", "components.UserList.createuser": "Crea un utente", "components.UserList.createlocaluser": "Crea un utente locale", @@ -482,7 +482,7 @@ "components.RequestButton.request": "Richiedi", "components.Login.validationpasswordrequired": "Password richiesta", "components.Login.validationemailrequired": "Indirizzo di posta elettronica non valido", - "components.Login.signinwithoverseerr": "Accedi a Overseerr", + "components.Login.signinwithoverseerr": "Accedi con Overseerr", "components.Login.password": "Password", "components.Login.loginerror": "Qualcosa è andato storto durante il tentativo di accesso", "components.Login.login": "Accedi", @@ -501,5 +501,66 @@ "components.UserEdit.request4kMoviesDescription": "Concede l'autorizzazione a richiedere film in 4K.", "components.UserEdit.request4kMovies": "Richiedi film 4K", "components.Settings.Notifications.NotificationsWebhook.authheader": "Intestazione di autorizzazione", - "components.MediaSlider.ShowMoreCard.seemore": "Vedi altro" + "components.MediaSlider.ShowMoreCard.seemore": "Vedi altro", + "components.RequestBlock.server": "Server", + "components.RequestBlock.rootfolder": "Cartella principale", + "components.RequestBlock.profilechanged": "Profilo modificato", + "components.NotificationTypeSelector.mediadeclinedDescription": "Invia una notifica quando una richiesta viene rifiutata.", + "components.NotificationTypeSelector.mediadeclined": "Media rifiutato", + "i18n.experimental": "Sperimentale", + "i18n.edit": "Modifica", + "components.UserEdit.request4kDescription": "Concede l'autorizzazione a richiedere film e serie 4K.", + "components.UserEdit.request4k": "Richiedi 4K", + "components.UserEdit.advancedrequestDescription": "Concede l'autorizzazione a utilizzare le opzioni di richiesta avanzate. (Es. Modifica di server / profili / percorsi)", + "components.UserEdit.advancedrequest": "Richieste avanzate", + "components.StatusBadge.status4k": "4K {status}", + "components.Settings.hideAvailable": "Nascondi i media disponibili", + "components.Settings.Notifications.NotificationsWebhook.webhooksettingssaved": "Impostazioni di notifica webhook salvate!", + "components.Settings.Notifications.NotificationsWebhook.webhooksettingsfailed": "Impossibile salvare le impostazioni di notifica del webhook.", + "components.Settings.Notifications.NotificationsWebhook.webhookUrlPlaceholder": "URL del webhook remoto", + "components.Settings.Notifications.NotificationsWebhook.webhookUrl": "URL del webhook", + "components.Settings.Notifications.NotificationsWebhook.validationWebhookUrlRequired": "Devi fornire un URL webhook.", + "components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "Devi fornire un Payload JSON.", + "components.Settings.Notifications.NotificationsWebhook.test": "Test", + "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON ripristinato al payload Standard.", + "components.Settings.Notifications.NotificationsWebhook.resetPayload": "Ripristina il Payload JSON Standard", + "components.Settings.Notifications.NotificationsWebhook.notificationtypes": "Tipi di notifica", + "components.Settings.Notifications.NotificationsWebhook.customJson": "Payload JSON personalizzato", + "components.RequestModal.requestedited": "Richiesta modificata.", + "components.RequestModal.requestcancelled": "Richiesta annullata.", + "components.RequestModal.request4kfrom": "Al momento è presente una richiesta 4K in sospeso da {username}", + "components.RequestModal.pending4krequest": "Richiesta in attesa di {title} in 4K", + "components.RequestModal.errorediting": "Qualcosa è andato storto durante la modifica della richiesta.", + "components.RequestModal.autoapproval": "Approvazione automatica", + "components.RequestModal.AdvancedRequester.rootfolder": "Cartella principale", + "components.RequestModal.AdvancedRequester.qualityprofile": "Profilo di qualità", + "components.RequestModal.AdvancedRequester.loadingprofiles": "Caricamento dei profili…", + "components.RequestModal.AdvancedRequester.loadingfolders": "Caricamento cartelle…", + "components.RequestModal.AdvancedRequester.destinationserver": "Server di destinazione", + "components.RequestModal.AdvancedRequester.default": "(Standard)", + "components.RequestModal.AdvancedRequester.animenote": "* Questa serie è un anime.", + "components.RequestModal.AdvancedRequester.advancedoptions": "Opzioni avanzate", + "components.RequestButton.declinerequests": "Rifiuta {requestCount} {requestCount, plural, one {richiesta} other {richieste}}", + "components.RequestBlock.requestoverrides": "Aggiramenti della richiesta", + "components.Settings.notificationsettingssaved": "Impostazioni di notifica salvate!", + "components.Settings.notificationsettingsfailed": "Impossibile salvare le impostazioni di notifica.", + "components.Settings.notificationAgentsSettings": "Agenti di notifica", + "components.UserList.bulkedit": "Modifica collettiva", + "components.UserList.userssaved": "Utenti salvati", + "components.PermissionEdit.vote": "Voto", + "components.PermissionEdit.users": "Gestisci gli utenti", + "components.PermissionEdit.settings": "Gestisci le impostazioni", + "components.PermissionEdit.request4kTv": "Richiedi serie 4K", + "components.PermissionEdit.request4kMovies": "Rechiedi film 4K", + "components.PermissionEdit.managerequests": "Gestisci le richieste", + "components.PermissionEdit.autoapproveSeries": "Approva automaticamente le serie", + "components.PermissionEdit.autoapproveMovies": "Approva automaticamente i film", + "components.PermissionEdit.autoapprove": "Approvazione automatica", + "components.PermissionEdit.advancedrequest": "Richieste avanzate", + "components.PermissionEdit.adminDescription": "Accesso amministratore completo. Aggira tutti gli altri permessi.", + "components.PermissionEdit.admin": "Amministratore", + "components.Login.signinwithplex": "Accedi con Plex", + "components.Login.signinheader": "Accedi per continuare", + "components.Login.signingin": "Accesso in corso…", + "components.Login.signin": "Accedi" } diff --git a/src/i18n/locale/ja.json b/src/i18n/locale/ja.json index 8baac769..1b54e78c 100644 --- a/src/i18n/locale/ja.json +++ b/src/i18n/locale/ja.json @@ -31,7 +31,7 @@ "components.MovieDetails.manageModalTitle": "映画を管理", "components.MovieDetails.originallanguage": "原語", "components.MovieDetails.overview": "ストーリー", - "components.MovieDetails.overviewunavailable": "ストーリー情報がありません", + "components.MovieDetails.overviewunavailable": "ストーリー情報がありません。", "components.MovieDetails.pending": "リクエスト中", "components.MovieDetails.recommendations": "オススメの作品", "components.MovieDetails.recommendationssubtext": "{title}が好きだった人は、こんなのも好きかもしれません…", @@ -75,7 +75,7 @@ "components.RequestModal.numberofepisodes": "エピソード数", "components.RequestModal.pendingrequest": "{title}がリクエスト中", "components.RequestModal.request": "リクエストする", - "components.RequestModal.requestCancel": "{title}のリクエストは取り消されました", + "components.RequestModal.requestCancel": "{title}のリクエストは取り消されました。", "components.RequestModal.requestSuccess": "{title}のリクエストは完了しました。", "components.RequestModal.requestadmin": "このリクエストは今すぐ承認されます。", "components.RequestModal.requestfrom": "{username}はすでにリクエストを上げています", @@ -126,7 +126,7 @@ "components.Settings.RadarrModal.ssl": "SSL", "components.Settings.RadarrModal.test": "テストする", "components.Settings.RadarrModal.testing": "テスト中…", - "components.Settings.RadarrModal.toastRadarrTestFailure": "Radarrサーバーの接続は失敗しました", + "components.Settings.RadarrModal.toastRadarrTestFailure": "Radarrサーバーの接続は失敗しました。", "components.Settings.RadarrModal.toastRadarrTestSuccess": "Radarrサーバーの接続は成功しました!", "components.Settings.RadarrModal.validationApiKeyRequired": "APIキーの入力が必要です", "components.Settings.RadarrModal.validationHostnameRequired": "ホスト名/IPの入力が必要です", @@ -170,7 +170,7 @@ "components.Settings.apikey": "APIキー", "components.Settings.applicationurl": "アプリケーションURL", "components.Settings.cancelscan": "スキャンをキャンセル", - "components.Settings.copied": "APIキーをクリップボードにコピーされた", + "components.Settings.copied": "APIキーをクリップボードにコピーされた。", "components.Settings.currentlibrary": "現在のライブラリー: {name}", "components.Settings.default": "デフォルト", "components.Settings.default4k": "デフォルト4K", @@ -221,7 +221,7 @@ "components.Setup.loginwithplex": "Plexでログイン", "components.Setup.signinMessage": "Plexアカウントでログインして始める", "components.Setup.welcome": "Overseerrへようこそ", - "components.Slider.noresults": "結果はありません", + "components.Slider.noresults": "結果はありません。", "components.TitleCard.movie": "映画", "components.TitleCard.tvshow": "シリーズ", "components.TvDetails.approve": "承認", @@ -238,7 +238,7 @@ "components.TvDetails.manageModalTitle": "シリーズの管理", "components.TvDetails.originallanguage": "言語", "components.TvDetails.overview": "ストーリー", - "components.TvDetails.overviewunavailable": "ストーリー情報がありません", + "components.TvDetails.overviewunavailable": "ストーリー情報がありません。", "components.TvDetails.pending": "リクエスト中", "components.TvDetails.recommendations": "オススメの作品", "components.TvDetails.recommendationssubtext": "{title}が好きだった人は、こんなのも好きかもしれません…", @@ -313,7 +313,7 @@ "components.Settings.SettingsAbout.overseerrinformation": "Overseerr情報", "components.Settings.SettingsAbout.githubdiscussions": "GitHubディスカッション", "components.Settings.SettingsAbout.gettingsupport": "サポート", - "components.Settings.SettingsAbout.clickheretojoindiscord": "Discordサーバーの参加はこちら。", + "components.Settings.SettingsAbout.clickheretojoindiscord": "Discordサーバーの参加はこちら!", "components.Settings.RadarrModal.validationNameRequired": "サーバー名を指定してください", "components.Settings.Notifications.emailsettingssaved": "メール通知設定が保存されました!", "components.Settings.Notifications.emailsettingsfailed": "メール通知設定の保存に失敗しました。", @@ -321,8 +321,8 @@ "components.Settings.Notifications.discordsettingssaved": "ディスコードの通知設定が保存されました!", "components.MovieDetails.MovieCast.fullcast": "フルキャスト", "i18n.deleting": "削除中…", - "components.UserList.userdeleteerror": "ユーザーの削除する時に問題が発生しました", - "components.UserList.userdeleted": "ユーザーが削除されました", + "components.UserList.userdeleteerror": "ユーザーの削除する時に問題が発生しました。", + "components.UserList.userdeleted": "ユーザーが削除されました。", "components.UserList.deleteuser": "ユーザーの削除", "components.UserList.deleteconfirm": "このユーザーを削除しますか?このユーザーの既存のリクエストデータはすべて削除されます。", "components.TvDetails.showtype": "番組タイプ", @@ -372,7 +372,7 @@ "components.CollectionDetails.requestcollection": "リクエストコレクション", "components.CollectionDetails.requestSuccess": "{title} をリクエストしました!", "components.CollectionDetails.request": "リクエスト", - "components.CollectionDetails.overviewunavailable": "ストーリー情報ありません", + "components.CollectionDetails.overviewunavailable": "ストーリー情報ありません。", "components.CollectionDetails.overview": "ストーリー", "components.CollectionDetails.numberofmovies": "作品数: {count}", "components.CollectionDetails.movies": "映画", @@ -380,7 +380,7 @@ "components.TvDetails.watchtrailer": "予告編を見る", "components.MovieDetails.watchtrailer": "予告編を見る", "components.MovieDetails.view": "表示", - "components.UserList.importfromplexerror": "Plexからユーザーをインポート中に問題が発生しました", + "components.UserList.importfromplexerror": "Plexからユーザーをインポート中に問題が発生しました。", "components.UserList.importfromplex": "Plexからユーザーをインポート", "components.UserList.importedfromplex": "Plexから{userCount, plural, =0 {新ユーザーはインポートされませんでした。} one {新ユーザー #名をインポートしました。} other {新ユーザー #名をインポートしました。}}", "components.TvDetails.viewfullcrew": "フルクルーを表示", @@ -397,13 +397,13 @@ "components.UserEdit.autoapproveMovies": "ムービーの自動承認", "components.Settings.Notifications.NotificationsSlack.webhookUrlPlaceholder": "ウェブフックURL", "components.Settings.Notifications.NotificationsSlack.webhookUrl": "ウェブフックURL", - "components.Settings.Notifications.NotificationsSlack.validationWebhookUrlRequired": "ウェブフックのURLを指定してください。", + "components.Settings.Notifications.NotificationsSlack.validationWebhookUrlRequired": "ウェブフックのURLを指定してください", "components.Settings.Notifications.NotificationsSlack.testsent": "テスト通知が送信されました。", "components.Settings.Notifications.NotificationsSlack.test": "テスト", "components.Settings.Notifications.NotificationsSlack.slacksettingssaved": "Slackの通知設定が保存されました", "components.Settings.Notifications.NotificationsSlack.slacksettingsfailed": "Slackの通知設定の保存に失敗しました。", "components.Settings.Notifications.NotificationsSlack.settingupslackDescription": "Slack通知を使用するには、受信ウェブフックを作成し、以下のWebhook URLを使用する必要があります。", "components.Settings.Notifications.NotificationsSlack.settingupslack": "Slack通知の設定", - "components.Settings.Notifications.NotificationsSlack.saving": "保存中...", + "components.Settings.Notifications.NotificationsSlack.saving": "保存中…", "components.Settings.Notifications.NotificationsSlack.save": "変更を保存" } diff --git a/src/i18n/locale/nl.json b/src/i18n/locale/nl.json index 8787d770..2e503791 100644 --- a/src/i18n/locale/nl.json +++ b/src/i18n/locale/nl.json @@ -16,7 +16,7 @@ "components.Layout.Sidebar.settings": "Instellingen", "components.Layout.Sidebar.users": "Gebruikers", "components.Layout.UserDropdown.signout": "Uitloggen", - "components.Layout.alphawarning": "Dit is ALPHA software. Bijna alles is waarschijnlijk kapot of instabiel. Rapporteer issues bij de Overseerr GitHub!", + "components.Layout.alphawarning": "Dit is ALPHA software. Functies kunnen kapot of instabiel zijn. Rapporteer issues op GitHub!", "components.Login.signinplex": "Log in om verder te gaan", "components.MovieDetails.approve": "Goedkeuren", "components.MovieDetails.available": "Beschikbaar", @@ -25,13 +25,13 @@ "components.MovieDetails.cast": "Cast", "components.MovieDetails.decline": "Weigeren", "components.MovieDetails.manageModalClearMedia": "Wis alle mediadata", - "components.MovieDetails.manageModalClearMediaWarning": "Dit wist alle mediadata voor dit item, inclusief alle verzoeken, zonder mogelijkheid tot herstel. Als dit item in je Plex-bibliotheek staat, zal alle media-info bij de volgende synchronisatie hersteld worden.", + "components.MovieDetails.manageModalClearMediaWarning": "Dit wist alle mediadata voor dit item onherroepelijk, inclusief eventuele verzoeken. Als dit item in je Plex-bibliotheek staat, zal alle media-informatie bij de volgende synchronisatie hersteld worden.", "components.MovieDetails.manageModalNoRequests": "Geen verzoeken", "components.MovieDetails.manageModalRequests": "Verzoeken", "components.MovieDetails.manageModalTitle": "Film beheren", "components.MovieDetails.originallanguage": "Originele taal", "components.MovieDetails.overview": "Overzicht", - "components.MovieDetails.overviewunavailable": "Overzicht niet beschikbaar", + "components.MovieDetails.overviewunavailable": "Overzicht niet beschikbaar.", "components.MovieDetails.pending": "In behandeling", "components.MovieDetails.recommendations": "Aanbevelingen", "components.MovieDetails.recommendationssubtext": "Als je {title} leuk vond, vind je dit misschien ook leuk…", @@ -50,7 +50,7 @@ "components.PersonDetails.nobiography": "Geen biografie beschikbaar.", "components.PlexLoginButton.loading": "Bezig met laden…", "components.PlexLoginButton.loggingin": "Bezig met inloggen…", - "components.PlexLoginButton.loginwithplex": "Inloggen met Plex", + "components.PlexLoginButton.loginwithplex": "Inloggen", "components.RequestBlock.seasons": "Seizoenen", "components.RequestCard.all": "Alle", "components.RequestCard.requestedby": "Aangevraagd door {username}", @@ -75,7 +75,7 @@ "components.RequestModal.numberofepisodes": "Aantal afleveringen", "components.RequestModal.pendingrequest": "Verzoek voor {title} in behandeling", "components.RequestModal.request": "Aanvragen", - "components.RequestModal.requestCancel": "Verzoek voor {title} is geannuleerd", + "components.RequestModal.requestCancel": "Verzoek voor {title} is geannuleerd.", "components.RequestModal.requestSuccess": "{title} is aangevraagd.", "components.RequestModal.requestadmin": "Je verzoek zal onmiddelijk goedgekeurd worden.", "components.RequestModal.requestfrom": "Er is een verzoek van {user} in behandeling", @@ -87,21 +87,21 @@ "components.RequestModal.selectseason": "Selecteer seizoen(en)", "components.RequestModal.status": "Status", "components.Search.searchresults": "Zoekresultaten", - "components.Settings.Notifications.agentenabled": "Agent ingeschakeld", + "components.Settings.Notifications.agentenabled": "Agent inschakelen", "components.Settings.Notifications.authPass": "SMTP-wachtwoord", "components.Settings.Notifications.authUser": "SMTP-gebruikersnaam", - "components.Settings.Notifications.emailsender": "E-mailadres van afzender", + "components.Settings.Notifications.emailsender": "E-mailadres afzender", "components.Settings.Notifications.enableSsl": "SSL inschakelen", "components.Settings.Notifications.save": "Wijzigingen opslaan", "components.Settings.Notifications.saving": "Bezig met opslaan…", "components.Settings.Notifications.smtpHost": "SMTP-host", "components.Settings.Notifications.smtpPort": "SMTP-poort", - "components.Settings.Notifications.validationFromRequired": "Je moet een afzenderadres opgeven.", - "components.Settings.Notifications.validationSmtpHostRequired": "Je moet een SMTP-host opgeven.", - "components.Settings.Notifications.validationSmtpPortRequired": "Je moet een SMTP-poort opgeven.", - "components.Settings.Notifications.validationWebhookUrlRequired": "Je moet een webhook-URL opgeven.", + "components.Settings.Notifications.validationFromRequired": "Je moet een afzenderadres opgeven", + "components.Settings.Notifications.validationSmtpHostRequired": "Je moet een SMTP-host opgeven", + "components.Settings.Notifications.validationSmtpPortRequired": "Je moet een SMTP-poort opgeven", + "components.Settings.Notifications.validationWebhookUrlRequired": "Je moet een webhook-URL opgeven", "components.Settings.Notifications.webhookUrl": "Webhook-URL", - "components.Settings.Notifications.webhookUrlPlaceholder": "Serverinstellingen -> Integraties -> Webhooks", + "components.Settings.Notifications.webhookUrlPlaceholder": "Serverinstellingen → Integraties → Webhooks", "components.Settings.RadarrModal.add": "Server toevoegen", "components.Settings.RadarrModal.apiKey": "API-sleutel", "components.Settings.RadarrModal.apiKeyPlaceholder": "Je Radarr API-sleutel", @@ -126,13 +126,13 @@ "components.Settings.RadarrModal.ssl": "SSL", "components.Settings.RadarrModal.test": "Test", "components.Settings.RadarrModal.testing": "Bezig met testen…", - "components.Settings.RadarrModal.toastRadarrTestFailure": "Kon niet verbinden met de Radarr-server", + "components.Settings.RadarrModal.toastRadarrTestFailure": "Kon niet verbinden met Radarr.", "components.Settings.RadarrModal.toastRadarrTestSuccess": "Verbonden met Radarr-server!", - "components.Settings.RadarrModal.validationApiKeyRequired": "Je moet een API-sleutel opgeven.", - "components.Settings.RadarrModal.validationHostnameRequired": "Je moet een hostnaam/IP opgeven.", - "components.Settings.RadarrModal.validationPortRequired": "Je moet een poort opgeven.", - "components.Settings.RadarrModal.validationProfileRequired": "Je moet een kwaliteitsprofiel selecteren.", - "components.Settings.RadarrModal.validationRootFolderRequired": "Je moet een hoofdmap selecteren.", + "components.Settings.RadarrModal.validationApiKeyRequired": "Je moet een API-sleutel opgeven", + "components.Settings.RadarrModal.validationHostnameRequired": "Je moet een hostnaam/IP opgeven", + "components.Settings.RadarrModal.validationPortRequired": "Je moet een poort opgeven", + "components.Settings.RadarrModal.validationProfileRequired": "Je moet een kwaliteitsprofiel selecteren", + "components.Settings.RadarrModal.validationRootFolderRequired": "Je moet een hoofdmap selecteren", "components.Settings.SonarrModal.add": "Server toevoegen", "components.Settings.SonarrModal.apiKey": "API-sleutel", "components.Settings.SonarrModal.apiKeyPlaceholder": "Je Sonarr API-sleutel", @@ -158,11 +158,11 @@ "components.Settings.SonarrModal.testing": "Bezig met testen…", "components.Settings.SonarrModal.toastRadarrTestFailure": "Kon niet verbinden met de Sonarr-server", "components.Settings.SonarrModal.toastRadarrTestSuccess": "Verbonden met Sonarr-server!", - "components.Settings.SonarrModal.validationApiKeyRequired": "Je moet een API-sleutel opgeven.", - "components.Settings.SonarrModal.validationHostnameRequired": "Je moet een hostnaam/IP opgeven.", - "components.Settings.SonarrModal.validationPortRequired": "Je moet een poort opgeven.", - "components.Settings.SonarrModal.validationProfileRequired": "Je moet een kwaliteitsprofiel selecteren.", - "components.Settings.SonarrModal.validationRootFolderRequired": "Je moet een hoofdmap selecteren.", + "components.Settings.SonarrModal.validationApiKeyRequired": "Je moet een API-sleutel opgeven", + "components.Settings.SonarrModal.validationHostnameRequired": "Je moet een hostnaam/IP opgeven", + "components.Settings.SonarrModal.validationPortRequired": "Je moet een poort opgeven", + "components.Settings.SonarrModal.validationProfileRequired": "Je moet een kwaliteitsprofiel selecteren", + "components.Settings.SonarrModal.validationRootFolderRequired": "Je moet een hoofdmap selecteren", "components.Settings.activeProfile": "Actief profiel", "components.Settings.addradarr": "Radarr-server toevoegen", "components.Settings.address": "Adres", @@ -170,7 +170,7 @@ "components.Settings.apikey": "API-sleutel", "components.Settings.applicationurl": "Applicatie-URL", "components.Settings.cancelscan": "Scan annuleren", - "components.Settings.copied": "API-sleutel gekopieerd naar klembord", + "components.Settings.copied": "API-sleutel gekopieerd naar klembord.", "components.Settings.currentlibrary": "Huidige bibliotheek: {name}", "components.Settings.default": "Standaard", "components.Settings.default4k": "Standaard 4K", @@ -178,12 +178,12 @@ "components.Settings.deleteserverconfirm": "Weet je zeker dat je deze server wilt verwijderen?", "components.Settings.edit": "Wijzigen", "components.Settings.generalsettings": "Algemene instellingen", - "components.Settings.generalsettingsDescription": "Deze instellingen hebben betrekking op de algemene configuratie van Overseer.", + "components.Settings.generalsettingsDescription": "Algemene en standaardinstellingen van Overseerr configureren.", "components.Settings.hostname": "Hostnaam/IP", "components.Settings.jobname": "Taaknaam", "components.Settings.librariesRemaining": "Resterende bibliotheken: {count}", "components.Settings.manualscan": "Handmatige bibliotheekscan", - "components.Settings.manualscanDescription": "Normaal wordt dit eens elke 24 uur uitgevoerd. Overseerr controleert de recent toegevoegde items van je Plex-server agressiever. Als je Plex voor de eerste keer configureert, is een handmatige volledige bibliotheekscan aanbevolen!", + "components.Settings.manualscanDescription": "Normaal wordt dit eens elke 24 uur uitgevoerd. Overseerr controleert de recent toegevoegde items van je Plex-server agressiever. Als je Plex voor de eerste keer configureert, is een eenmalige handmatige volledige bibliotheekscan aanbevolen!", "components.Settings.menuAbout": "Over", "components.Settings.menuGeneralSettings": "Algemene instellingen", "components.Settings.menuJobs": "Taken", @@ -193,21 +193,21 @@ "components.Settings.menuServices": "Diensten", "components.Settings.nextexecution": "Volgende uitvoering", "components.Settings.notificationsettings": "Meldingsinstellingen", - "components.Settings.notificationsettingsDescription": "Hier kun je kiezen welke soort meldingen er worden verstuurd en door welke type diensten.", + "components.Settings.notificationsettingsDescription": "Configureer algemene meldingsinstellingen. De onderstaande opties zijn van toepassing op alle meldingsagenten.", "components.Settings.notrunning": "Niet actief", "components.Settings.plexlibraries": "Plex-bibliotheken", - "components.Settings.plexlibrariesDescription": "De bibliotheken die Overseerr scant voor titels. Stel je Plex-verbinding in en sla ze op. Klik op de onderstaande knop als er geen verbindingen staan.", + "components.Settings.plexlibrariesDescription": "De bibliotheken die Overseerr scant voor titels. Stel je Plex-verbinding in en sla ze op. Klik daarna op de onderstaande knop als er geen bibliotheken staan.", "components.Settings.plexsettings": "Plex-instellingen", - "components.Settings.plexsettingsDescription": "Configureer de instellingen voor je Plex-server. Overseerr gebruikt je Plex-server om je bibliotheek regelmatig te scannen, om te zien welke content beschikbaar is.", + "components.Settings.plexsettingsDescription": "Configureer de instellingen voor je Plex-server. Overseerr scant je Plex-bibliotheken om te zien welke content beschikbaar is.", "components.Settings.port": "Poort", - "components.Settings.radarrSettingsDescription": "Stel hier onder je Radarr-verbinding in. Je kan er meerdere hebben, maar slechts twee actief hebben als standaard (één voor standaard HD en één voor 4K). Beheerders kunnen bepalen welke server gebruikt wordt voor nieuwe verzoeken.", + "components.Settings.radarrSettingsDescription": "Stel hier onder je Radarr-verbinding in. Je kan er meerdere hebben, maar slechts twee actief hebben als standaard (één voor standaard HD en één voor 4K). Beheerders kunnen aanpassen welke server gebruikt wordt voor nieuwe verzoeken.", "components.Settings.radarrsettings": "Radarr-instellingen", "components.Settings.runnow": "Nu starten", "components.Settings.save": "Wijzigingen opslaan", "components.Settings.saving": "Bezig met opslaan…", - "components.Settings.servername": "Servernaam (wordt automatisch ingevuld na opslaan)", + "components.Settings.servername": "Servernaam", "components.Settings.servernamePlaceholder": "Servernaam Plex", - "components.Settings.sonarrSettingsDescription": "Stel hier onder je Sonarr-verbinding in. Je kan er meerdere hebben, maar slechts twee actief hebben als standaard (één voor standaard HD en één voor 4K). Beheerders kunnen bepalen welke server gebruikt wordt voor nieuwe verzoeken.", + "components.Settings.sonarrSettingsDescription": "Stel hier onder je Sonarr-verbinding in. Je kan er meerdere hebben, maar slechts twee actief hebben als standaard (één voor standaard HD en één voor 4K). Beheerders kunnen aanpassen welke server gebruikt wordt voor nieuwe verzoeken.", "components.Settings.sonarrsettings": "Sonarr-instellingen", "components.Settings.ssl": "SSL", "components.Settings.startscan": "Scan starten", @@ -221,7 +221,7 @@ "components.Setup.loginwithplex": "Inloggen met Plex", "components.Setup.signinMessage": "Ga aan de slag door in te loggen met je Plex-account", "components.Setup.welcome": "Welkom bij Overseerr", - "components.Slider.noresults": "Geen resultaten", + "components.Slider.noresults": "Geen resultaten.", "components.TitleCard.movie": "Film", "components.TitleCard.tvshow": "Serie", "components.TvDetails.approve": "Goedkeuren", @@ -232,13 +232,13 @@ "components.TvDetails.decline": "Weigeren", "components.TvDetails.declinerequests": "Weiger {requestCount} {requestCount, plural, één {Request} meerdere {Requests}}", "components.TvDetails.manageModalClearMedia": "Wis alle media-data", - "components.TvDetails.manageModalClearMediaWarning": "Wist alle media-data inclusief alle verzoeken voor dit item zonder herstelmogelijkheden. Als dit item in je Plex-bibliotheek bestaat, zal alle media-info bij de volgende sync hersteld worden.", + "components.TvDetails.manageModalClearMediaWarning": "Dit wist alle mediadata voor dit item onherroepelijk, inclusief eventuele verzoeken. Als dit item in je Plex-bibliotheek staat, zal alle media-informatie bij de volgende synchronisatie hersteld worden.", "components.TvDetails.manageModalNoRequests": "Geen verzoeken", "components.TvDetails.manageModalRequests": "Verzoeken", "components.TvDetails.manageModalTitle": "Serie beheren", "components.TvDetails.originallanguage": "Originele taal", "components.TvDetails.overview": "Overzicht", - "components.TvDetails.overviewunavailable": "Overzicht niet beschikbaar", + "components.TvDetails.overviewunavailable": "Overzicht niet beschikbaar.", "components.TvDetails.pending": "In behandeling", "components.TvDetails.recommendations": "Aanbevelingen", "components.TvDetails.recommendationssubtext": "Als je {title} leuk vond, vind je dit misschien ook leuk…", @@ -266,7 +266,7 @@ "components.UserEdit.settings": "Instellingen beheren", "components.UserEdit.settingsDescription": "Geeft toestemming om alle Overseerr-instellingen te wijzigen. Een gebruiker heeft deze machtiging nodig om ze aan anderen te verlenen.", "components.UserEdit.userfail": "Er ging iets mis bij het opslaan van de gebruiker.", - "components.UserEdit.username": "Gebruikersnaam", + "components.UserEdit.username": "Weergavenaam", "components.UserEdit.users": "Gebruikers beheren", "components.UserEdit.usersDescription": "Geeft toestemming om Overseerr-gebruikers te beheren. Gebruikers met deze machtiging kunnen gebruikers met beheerdersrechten niet wijzigen of deze toestemming geven.", "components.UserEdit.usersaved": "Gebruiker opgeslagen", @@ -309,10 +309,10 @@ "components.Settings.Notifications.emailsettingsfailed": "Instellingen voor e-mailmeldingen konden niet opgeslagen worden.", "components.Settings.Notifications.discordsettingssaved": "Instellingen voor Discord-meldingen zijn opgeslagen!", "components.Settings.Notifications.discordsettingsfailed": "Instellingen voor Discord-meldingen konden niet opgeslagen worden.", - "components.Settings.validationPortRequired": "Je moet een poort opgeven.", - "components.Settings.validationHostnameRequired": "Je moet een hostnaam/IP opgeven.", - "components.Settings.SonarrModal.validationNameRequired": "Je moet een servernaam opgeven.", - "components.Settings.RadarrModal.validationNameRequired": "Je moet een servernaam opgeven.", + "components.Settings.validationPortRequired": "Je moet een poort opgeven", + "components.Settings.validationHostnameRequired": "Je moet een hostnaam/IP opgeven", + "components.Settings.SonarrModal.validationNameRequired": "Je moet een servernaam opgeven", + "components.Settings.RadarrModal.validationNameRequired": "Je moet een servernaam opgeven", "components.Settings.SettingsAbout.version": "Versie", "components.Settings.SettingsAbout.totalrequests": "Totaal aantal verzoeken", "components.Settings.SettingsAbout.totalmedia": "Totale aantal media", @@ -323,25 +323,25 @@ "components.Settings.nodefaultdescription": "Tenminste één server moet geselecteerd zijn als standaard voordat verzoeken doorkomen bij je diensten.", "components.Settings.nodefault": "Geen standaardserver geselecteerd!", "components.Settings.no4kimplemented": "(Standaard 4K servers zijn momenteel niet geïmplementeerd)", - "components.Settings.SonarrModal.testFirstRootFolders": "Test je verbinding om hoofdmappen te laden", - "components.Settings.SonarrModal.testFirstQualityProfiles": "Test je verbinding om kwaliteitsprofielen te laden", + "components.Settings.SonarrModal.testFirstRootFolders": "Test verbinding om hoofdmappen te laden", + "components.Settings.SonarrModal.testFirstQualityProfiles": "Test verbinding om kwaliteitsprofielen te laden", "components.Settings.SonarrModal.loadingrootfolders": "Bezig met laden van hoofdmappen…", "components.Settings.SonarrModal.loadingprofiles": "Bezig met laden van kwaliteitsprofielen…", "components.Settings.SettingsAbout.gettingsupport": "Ondersteuning krijgen", - "components.Settings.SettingsAbout.clickheretojoindiscord": "Klik hier om lid te worden van onze Discord-server.", - "components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "Je moet een minimale beschikbaarheid selecteren.", - "components.Settings.RadarrModal.testFirstRootFolders": "Test je verbinding om hoofdmappen te laden", - "components.Settings.RadarrModal.testFirstQualityProfiles": "Test je verbinding om kwaliteitsprofielen te laden", + "components.Settings.SettingsAbout.clickheretojoindiscord": "Klik hier om lid te worden van onze Discord-server!", + "components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "Je moet een minimale beschikbaarheid selecteren", + "components.Settings.RadarrModal.testFirstRootFolders": "Test verbinding om hoofdmappen te laden", + "components.Settings.RadarrModal.testFirstQualityProfiles": "Test verbinding om kwaliteitsprofielen te laden", "components.Settings.RadarrModal.loadingrootfolders": "Bezig met laden van hoofdmappen…", "components.Settings.RadarrModal.loadingprofiles": "Bezig met laden van kwaliteitsprofielen…", - "components.Settings.SettingsAbout.Releases.releasedataMissing": "Publicatiedatum ontbreekt. Is GitHub offline?", + "components.Settings.SettingsAbout.Releases.releasedataMissing": "Versiegegevens niet beschikbaar. Is GitHub offline?", "components.Settings.SettingsAbout.Releases.latestversion": "Laatste versie", "components.Settings.SettingsAbout.Releases.currentversion": "Huidige versie", "components.Settings.Notifications.testsent": "Testmelding verzonden!", "components.Settings.Notifications.test": "Test", "components.MovieDetails.studio": "Studio", "components.CollectionDetails.requesting": "Bezig met aanvragen…", - "components.CollectionDetails.overviewunavailable": "Overzicht niet beschikbaar", + "components.CollectionDetails.overviewunavailable": "Overzicht niet beschikbaar.", "components.CollectionDetails.overview": "Overzicht", "components.CollectionDetails.numberofmovies": "Aantal films: {count}", "components.CollectionDetails.movies": "Films", @@ -356,9 +356,9 @@ "i18n.failed": "Mislukt", "i18n.deleting": "Bezig met verwijderen…", "i18n.close": "Sluiten", - "components.UserList.userdeleteerror": "Er ging iets mis bij het verwijderen van de gebruiker", - "components.UserList.userdeleted": "Gebruiker verwijderd", - "components.UserList.importfromplexerror": "Er is iets misgegaan bij het importeren van gebruikers uit Plex", + "components.UserList.userdeleteerror": "Er ging iets mis bij het verwijderen van de gebruiker.", + "components.UserList.userdeleted": "Gebruiker verwijderd.", + "components.UserList.importfromplexerror": "Er is iets misgegaan bij het importeren van gebruikers uit Plex.", "components.UserList.importfromplex": "Gebruikers importeren uit Plex", "components.UserList.deleteuser": "Gebruiker verwijderen", "components.UserList.deleteconfirm": "Weet je zeker dat je deze gebruiker wilt verwijderen? Alle bestaande aanvraaggegevens van deze gebruiker zullen worden verwijderd.", @@ -376,7 +376,7 @@ "components.StatusChacker.newversionavailable": "Nieuwe versie beschikbaar", "components.StatusChacker.newversionDescription": "Er is een update beschikbaar. Klik op de onderstaande knop om de toepassing opnieuw te laden.", "components.Settings.toastSettingsSuccess": "Instellingen opgeslagen.", - "components.Settings.toastSettingsFailure": "Er ging iets mis met het opslaan van de instellingen.", + "components.Settings.toastSettingsFailure": "Er ging iets mis bij het opslaan van de instellingen.", "components.Settings.toastApiKeySuccess": "Nieuwe API-sleutel gegenereerd!", "components.Settings.toastApiKeyFailure": "Er ging iets mis bij het genereren van een nieuwe API-sleutel.", "components.Settings.defaultPermissions": "Standaard gebruikersrechten", @@ -388,13 +388,13 @@ "components.Settings.SettingsAbout.documentation": "Documentatie", "components.Settings.SettingsAbout.Releases.viewongithub": "Bekijken op GitHub", "components.Settings.SettingsAbout.Releases.viewchangelog": "Changelog bekijken", - "components.Settings.SettingsAbout.Releases.runningDevelopMessage": "De wijzigingen in je versie zijn hieronder niet beschikbaar. Kijk naar de GitHub repository voor de laatste updates.", + "components.Settings.SettingsAbout.Releases.runningDevelopMessage": "De wijzigingen in je versie zijn hieronder niet beschikbaar. Bekijk de GitHub repository voor de laatste updates.", "components.Settings.SettingsAbout.Releases.runningDevelop": "Je gebruikt een ontwikkelversie van Overseerr!", - "components.Settings.Notifications.validationChatIdRequired": "Je moet een Chat-ID opgeven.", - "components.Settings.Notifications.validationBotAPIRequired": "Je moet een Bot API-sleutel ingeven.", + "components.Settings.Notifications.validationChatIdRequired": "Je moet een Chat-ID opgeven", + "components.Settings.Notifications.validationBotAPIRequired": "Je moet een Bot API-sleutel ingeven", "components.Settings.Notifications.telegramsettingssaved": "Instellingen Telegrammeldingen opgeslagen!", "components.Settings.Notifications.telegramsettingsfailed": "De instellingen voor Telegrammeldingen konden niet opgeslagen worden.", - "components.Settings.Notifications.ssldisabletip": "SSL moet worden uitgeschakeld op standaard TLS-verbindingen (Poort 587)", + "components.Settings.Notifications.ssldisabletip": "SSL moet worden uitgeschakeld op standaard TLS-verbindingen (poort 587)", "components.Settings.Notifications.senderName": "Naam afzender", "components.Settings.Notifications.notificationtypes": "Meldingtypes", "components.Settings.Notifications.chatId": "Chat-ID", @@ -402,14 +402,14 @@ "components.Settings.Notifications.allowselfsigned": "Self-signed certificaten toestaan", "components.Settings.Notifications.NotificationsSlack.webhookUrlPlaceholder": "Webhook-URL", "components.Settings.Notifications.NotificationsSlack.webhookUrl": "Webhook-URL", - "components.Settings.Notifications.NotificationsSlack.validationWebhookUrlRequired": "Je moet een webhook-URL opgeven.", + "components.Settings.Notifications.NotificationsSlack.validationWebhookUrlRequired": "Je moet een webhook-URL opgeven", "components.Settings.Notifications.NotificationsSlack.testsent": "Testmelding verzonden!", "components.Settings.Notifications.NotificationsSlack.test": "Test", "components.Settings.Notifications.NotificationsSlack.saving": "Bezig met opslaan…", "components.Settings.Notifications.NotificationsSlack.save": "Wijzigingen opslaan", "components.Settings.Notifications.NotificationsSlack.notificationtypes": "Meldingtypes", - "components.Settings.Notifications.NotificationsSlack.agentenabled": "Agent ingeschakeld", - "components.RequestList.RequestItem.failedretry": "Er ging opnieuw iets mis met het aanvragen", + "components.Settings.Notifications.NotificationsSlack.agentenabled": "Agent inschakelen", + "components.RequestList.RequestItem.failedretry": "Er ging opnieuw iets mis tijdens het aanvragen.", "components.PersonDetails.crewmember": "Crewlid", "components.NotificationTypeSelector.mediarequested": "Media aangevraagd", "components.NotificationTypeSelector.mediaavailable": "Media beschikbaar", @@ -436,7 +436,7 @@ "components.Settings.Notifications.NotificationsPushover.pushoversettingssaved": "Instellingen voor Pushover-meldingen opgeslagen!", "components.Settings.Notifications.NotificationsPushover.pushoversettingsfailed": "Instellingen voor Pushover-meldingen konden niet opgeslagen worden.", "components.Settings.Notifications.NotificationsPushover.notificationtypes": "Meldingtypes", - "components.Settings.Notifications.NotificationsPushover.agentenabled": "Agent ingeschakeld", + "components.Settings.Notifications.NotificationsPushover.agentenabled": "Agent inschakelen", "components.Settings.Notifications.NotificationsPushover.accessToken": "Toegangstoken", "components.RequestList.sortModified": "Laatst gewijzigd", "components.RequestList.sortAdded": "Aanvraagdatum", @@ -445,8 +445,8 @@ "components.RequestList.filterPending": "In behandeling", "components.RequestList.filterApproved": "Goedgekeurd", "components.RequestList.filterAll": "Alle", - "components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "Je moet een gebruikerstoken opgeven.", - "components.Settings.Notifications.NotificationsPushover.validationAccessTokenRequired": "Je moet een toegangstoken opgeven.", + "components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "Je moet een gebruikerstoken opgeven", + "components.Settings.Notifications.NotificationsPushover.validationAccessTokenRequired": "Je moet een toegangstoken opgeven", "components.Settings.Notifications.NotificationsPushover.userToken": "Gebruikerstoken", "components.Settings.Notifications.NotificationsPushover.testsent": "Testmelding verzonden!", "components.Settings.Notifications.NotificationsPushover.test": "Test", @@ -471,21 +471,21 @@ "components.Settings.Notifications.NotificationsWebhook.webhooksettingsfailed": "Instellingen voor webhook-meldingen konden niet opgeslagen worden.", "components.Settings.Notifications.NotificationsWebhook.webhookUrlPlaceholder": "Externe webhook-URL", "components.Settings.Notifications.NotificationsWebhook.webhookUrl": "Webhook-URL", - "components.Settings.Notifications.NotificationsWebhook.validationWebhookUrlRequired": "Je moet een webhook-URL invoeren.", - "components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "Je moet een JSON-payload opgeven.", + "components.Settings.Notifications.NotificationsWebhook.validationWebhookUrlRequired": "Je moet een webhook-URL invoeren", + "components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "Je moet een JSON-payload opgeven", "components.Settings.Notifications.NotificationsWebhook.testsent": "Testmelding verzonden!", "components.Settings.Notifications.NotificationsWebhook.test": "Test", - "components.Settings.Notifications.NotificationsWebhook.templatevariablehelp": "Hulp bij sjabloonvariabelen", + "components.Settings.Notifications.NotificationsWebhook.templatevariablehelp": "Hulp met sjabloonvariabelen", "components.Settings.Notifications.NotificationsWebhook.saving": "Bezig met opslaan…", "components.Settings.Notifications.NotificationsWebhook.save": "Wijzigingen opslaan", - "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON teruggezet naar standaard payload.", - "components.Settings.Notifications.NotificationsWebhook.resetPayload": "Terugzetten naar standaard JSON-payload", + "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON payload met succes teruggezet.", + "components.Settings.Notifications.NotificationsWebhook.resetPayload": "Terugzetten naar standaard", "components.Settings.Notifications.NotificationsWebhook.notificationtypes": "Meldingtypes", "components.Settings.Notifications.NotificationsWebhook.customJson": "Aangepaste JSON-payload", "components.Settings.Notifications.NotificationsWebhook.authheader": "Autorisatie-header", - "components.Settings.Notifications.NotificationsWebhook.agentenabled": "Agent ingeschakeld", + "components.Settings.Notifications.NotificationsWebhook.agentenabled": "Agent inschakelen", "components.RequestModal.request4ktitle": "{title} in 4K aanvragen", - "components.RequestModal.request4kfrom": "Er is momenteel een 4K-verzoek van {username} in behandeling", + "components.RequestModal.request4kfrom": "Er is momenteel een 4K-verzoek van {username} in behandeling.", "components.RequestModal.pending4krequest": "Verzoek voor {title} in 4K in behandeling", "components.RequestButton.viewrequest4k": "4K-verzoek bekijken", "components.RequestButton.viewrequest": "Verzoek bekijken", @@ -499,21 +499,21 @@ "components.UserList.create": "Aanmaken", "components.UserList.createlocaluser": "Lokale gebruiker aanmaken", "components.UserList.createuser": "Gebruiker aanmaken", - "components.UserList.usercreatedfailed": "Er ging iets mis bij het aanmaken van de gebruiker", + "components.UserList.usercreatedfailed": "Er ging iets mis bij het aanmaken van de gebruiker.", "components.UserList.creating": "Bezig met aanmaken", - "components.UserList.validationpasswordminchars": "Wachtwoord is te kort - minimaal 8 tekens.", - "components.UserList.validationemailrequired": "Je moet een geldig e-mailadres invoeren.", - "components.UserList.usercreatedsuccess": "Gebruiker met succes aangemaakt", - "components.UserList.passwordinfodescription": "Instellingen voor e-mailmeldingen moeten ingeschakeld en ingesteld worden om de automatisch gegenereerde wachtwoorden te kunnen gebruiken", - "components.UserList.passwordinfo": "Wachtwoordinfo", + "components.UserList.validationpasswordminchars": "Wachtwoord is te kort; moet minimaal 8 tekens bevatten", + "components.UserList.validationemailrequired": "Je moet een geldig e-mailadres invoeren", + "components.UserList.usercreatedsuccess": "Gebruiker met succes aangemaakt!", + "components.UserList.passwordinfodescription": "Instellingen voor e-mailmeldingen moeten geconfigureerd en ingeschakeld worden om wachtwoorden automatisch te genereren.", + "components.UserList.passwordinfo": "Wachtwoordinformatie", "components.UserList.password": "Wachtwoord", "components.UserList.localuser": "Lokale gebruiker", "components.UserList.email": "E-mailadres", "components.Login.validationpasswordrequired": "Wachtwoord vereist", - "components.Login.validationemailrequired": "Geen geldig e-mailadres", - "components.Login.signinwithoverseerr": "Inloggen met Overseerr", + "components.Login.validationemailrequired": "Ongeldig e-mailadres", + "components.Login.signinwithoverseerr": "Overseerr-account gebruiken", "components.Login.password": "Wachtwoord", - "components.Login.loginerror": "Er ging iets mis bij het inloggen", + "components.Login.loginerror": "Er ging iets mis bij het inloggen.", "components.Login.login": "Inloggen", "components.Login.loggingin": "Bezig met inloggen…", "components.Login.goback": "Terug", @@ -536,7 +536,113 @@ "components.RequestBlock.server": "Server", "components.RequestBlock.rootfolder": "Hoofdmap", "components.RequestBlock.profilechanged": "Profiel gewijzigd", - "components.RequestBlock.requestoverrides": "Geannuleerde verzoeken", + "components.RequestBlock.requestoverrides": "Overschrijvingen van verzoek", "components.NotificationTypeSelector.mediadeclinedDescription": "Stuurt een melding wanneer een verzoek wordt afgewezen.", - "components.NotificationTypeSelector.mediadeclined": "Media geweigerd" + "components.NotificationTypeSelector.mediadeclined": "Media geweigerd", + "components.RequestModal.autoapproval": "Automatische goedkeuring", + "i18n.experimental": "Experimenteel", + "components.Settings.hideAvailable": "Beschikbare media verbergen", + "components.RequestModal.requesterror": "Er ging iets mis bij het aanvragen.", + "components.RequestModal.notvdbiddescription": "Voeg de TVDB-ID toe aan TMDb en probeer later opnieuw, of selecteer de juiste match hieronder:", + "components.RequestModal.notvdbid": "Er is geen TVDB-ID gevonden voor dit item op TMDb.", + "components.RequestModal.next": "Volgende", + "components.RequestModal.backbutton": "Terug", + "components.RequestModal.SearchByNameModal.notvdbiddescription": "We konden je verzoek niet automatisch matchen. Selecteer de juiste match uit de onderstaande lijst:", + "components.RequestModal.SearchByNameModal.notvdbid": "Manuele match vereist", + "components.RequestModal.SearchByNameModal.nosummary": "Er is geen samenvatting voor deze titel gevonden.", + "components.RequestModal.SearchByNameModal.next": "Volgende", + "components.Login.signinwithplex": "Plex-account gebruiken", + "components.Login.signinheader": "Log in om verder te gaan", + "components.Login.signingin": "Bezig met inloggen…", + "components.Login.signin": "Inloggen", + "components.Settings.notificationsettingssaved": "Meldingsinstellingen opgeslagen!", + "components.Settings.notificationsettingsfailed": "Meldingsinstellingen kunnen niet opgeslagen worden.", + "components.Settings.notificationAgentsSettings": "Meldingsagenten", + "components.Settings.notificationAgentSettingsDescription": "Kies de soorten meldingen die je wilt verzenden en welke meldingsagenten je wilt gebruiken.", + "components.Settings.enablenotifications": "Meldingen inschakelen", + "components.Settings.autoapprovedrequests": "Meldingen verzenden voor automatisch goedgekeurde verzoeken", + "components.PlexLoginButton.signinwithplex": "Inloggen", + "components.PlexLoginButton.signingin": "Bezig met inloggen…", + "components.PermissionEdit.advancedrequest": "Geavanceerde aanvragen", + "components.PermissionEdit.admin": "Beheerder", + "components.UserList.userssaved": "Gebruikers opgeslagen", + "components.Settings.toastPlexRefreshSuccess": "Serverlijst van Plex opgehaald.", + "components.Settings.toastPlexRefresh": "Bezig met serverlijst ophalen van Plex…", + "components.Settings.toastPlexConnecting": "Bezig met verbinden met Plex-server…", + "components.UserList.bulkedit": "Meerdere bewerken", + "components.Settings.toastPlexRefreshFailure": "Kan serverlijst van Plex niet ophalen!", + "components.Settings.toastPlexConnectingSuccess": "Verbonden met Plex-server.", + "components.Settings.toastPlexConnectingFailure": "Kan geen verbinding maken met Plex!", + "components.Settings.timeout": "Time-out", + "components.Settings.settingUpPlexDescription": "Om Plex in te stellen, kan je jouw gegevens handmatig invoeren of een server selecteren die is opgehaald van plex.tv. Druk op de knop rechts van de vervolgkeuzelijst om de verbinding te checken en beschikbare servers op te halen.", + "components.Settings.settingUpPlex": "Plex instellen", + "components.Settings.serverpresetRefreshing": "Bezig met servers ophalen…", + "components.Settings.serverpresetPlaceholder": "Plex-server", + "components.Settings.serverpresetManualMessage": "Handmatig configureren", + "components.Settings.serverpresetLoad": "Klik op de knop om de beschikbare servers te laden", + "components.Settings.serverpreset": "Server", + "components.Settings.serverRemote": "extern", + "components.Settings.serverLocal": "lokaal", + "components.Settings.serverConnected": "verbonden", + "components.Settings.ms": "ms", + "components.Settings.csrfProtectionTip": "Stelt externe API-toegang in op alleen-lezen (Overseerr moet opnieuw worden geladen om wijzigingen door te voeren)", + "components.Settings.csrfProtection": "CSRF-bescherming inschakelen", + "components.PermissionEdit.voteDescription": "Geeft toestemming om te stemmen op verzoeken (stemmen is nog niet geïmplementeerd)", + "components.PermissionEdit.vote": "Stemmen", + "components.PermissionEdit.usersDescription": "Geeft toestemming om Overseerr-gebruikers te beheren. Gebruikers met deze machtiging kunnen gebruikers met beheerdersrechten niet wijzigen of deze toestemming geven.", + "components.PermissionEdit.users": "Gebruikers beheren", + "components.PermissionEdit.settingsDescription": "Geeft toestemming om alle Overseerr-instellingen te wijzigen. Een gebruiker heeft deze machtiging nodig om ze aan anderen te verlenen.", + "components.PermissionEdit.settings": "Instellingen beheren", + "components.PermissionEdit.requestDescription": "Geeft toestemming om films en series aan te vragen.", + "components.PermissionEdit.request4kTvDescription": "Geeft toestemming om series in 4K aan te vragen.", + "components.PermissionEdit.request4kTv": "4K-series aanvragen", + "components.PermissionEdit.request4kMoviesDescription": "Geeft toestemming om films in 4K aan te vragen.", + "components.PermissionEdit.request4k": "4K aanvragen", + "components.PermissionEdit.request": "Aanvragen", + "components.PermissionEdit.request4kMovies": "4K-films aanvragen", + "components.PermissionEdit.request4kDescription": "Geeft toestemming om 4K-films en -series aan te vragen.", + "components.PermissionEdit.managerequestsDescription": "Geeft toestemming om verzoeken te beheren, inclusief het goedkeuren en weigeren ervan.", + "components.PermissionEdit.managerequests": "Verzoeken beheren", + "components.PermissionEdit.autoapproveSeriesDescription": "Keurt serieverzoeken van deze gebruiker automatisch goed.", + "components.PermissionEdit.autoapproveMovies": "Films automatisch goedkeuren", + "components.PermissionEdit.autoapproveSeries": "Series automatisch goedkeuren", + "components.PermissionEdit.autoapproveMoviesDescription": "Keurt filmverzoeken van deze gebruiker automatisch goed.", + "components.PermissionEdit.autoapproveDescription": "Geeft automatische goedkeuring voor alle verzoeken van deze gebruiker.", + "components.PermissionEdit.autoapprove": "Automatische goedkeuring", + "components.PermissionEdit.advancedrequestDescription": "Geeft toestemming om geavanceerde aanvraagopties te gebruiken; bv. servers, profielen of paden wijzigen.", + "components.PermissionEdit.adminDescription": "Volledige beheerderstoegang. Omzeilt alle machtigingscontroles.", + "components.Settings.servernameTip": "Automatisch opgehaald van Plex na opslaan", + "components.Settings.SonarrModal.toastSonarrTestSuccess": "Verbonden met Sonarr!", + "components.Settings.SonarrModal.toastSonarrTestFailure": "Kon niet verbinden met Sonarr.", + "components.Common.ListView.noresults": "Geen resultaten.", + "components.UserEdit.plexUsername": "Gebruikersnaam Plex", + "components.TvDetails.playonplex": "Afspelen op Plex", + "components.TvDetails.play4konplex": "Afspelen in 4K op Plex", + "components.TvDetails.opensonarr4k": "Serie openen in 4K Sonarr", + "components.TvDetails.opensonarr": "Serie openen in Sonarr", + "components.TvDetails.downloadstatus": "Downloadstatus", + "components.TvDetails.areyousure": "Weet je het zeker?", + "components.Settings.jobtype": "Type", + "components.Settings.jobstarted": "{jobname} gestart.", + "components.Settings.jobcancelled": "{jobname} geannuleerd.", + "components.Settings.canceljob": "Taak annuleren", + "components.Settings.SonarrModal.syncEnabled": "Synchronisatie inschakelen", + "components.Settings.SonarrModal.preventSearch": "Automatisch zoeken uitschakelen", + "components.Settings.SonarrModal.externalUrlPlaceholder": "Externe URL naar je Sonarr-server", + "components.Settings.SonarrModal.externalUrl": "Externe URL", + "components.Settings.RadarrModal.syncEnabled": "Synchronisatie inschakelen", + "components.Settings.RadarrModal.preventSearch": "Automatisch zoeken uitschakelen", + "components.Settings.RadarrModal.externalUrlPlaceholder": "Externe URL naar je Radarr-server", + "components.Settings.RadarrModal.externalUrl": "Externe URL", + "components.MovieDetails.playonplex": "Afspelen op Plex", + "components.MovieDetails.play4konplex": "Afspelen in 4K op Plex", + "components.MovieDetails.openradarr4k": "Film openen in 4K Radarr", + "components.MovieDetails.openradarr": "Film openen in Radarr", + "components.MovieDetails.downloadstatus": "Downloadstatus", + "components.MovieDetails.areyousure": "Weet je het zeker?", + "components.TvDetails.markavailable": "Als beschikbaar markeren", + "components.TvDetails.mark4kavailable": "Als beschikbaar in 4K markeren", + "components.TvDetails.allseasonsmarkedavailable": "* Alle seizoenen worden als beschikbaar gemarkeerd.", + "components.MovieDetails.mark4kavailable": "Als beschikbaar in 4K markeren", + "components.MovieDetails.markavailable": "Als beschikbaar markeren" } diff --git a/src/i18n/locale/pt_BR.json b/src/i18n/locale/pt_BR.json index 59c0f04f..14968b8b 100644 --- a/src/i18n/locale/pt_BR.json +++ b/src/i18n/locale/pt_BR.json @@ -8,7 +8,7 @@ "components.RequestCard.requestedby": "Solicitado por {username}", "components.RequestBlock.seasons": "Temporadas", "components.PlexLoginButton.loginwithplex": "Entrar com Plex", - "components.PlexLoginButton.loggingin": "Fazendo login…", + "components.PlexLoginButton.loggingin": "Autenticando…", "components.PlexLoginButton.loading": "Carregando…", "components.PersonDetails.nobiography": "Biografia não disponível.", "components.PersonDetails.ascharacter": "como {character}", @@ -27,13 +27,13 @@ "components.MovieDetails.recommendationssubtext": "Se você gostou de {title}, você provavelmente irá gostar…", "components.MovieDetails.recommendations": "Recomendações", "components.MovieDetails.pending": "Pendente", - "components.MovieDetails.overviewunavailable": "Sinopse indisponível", + "components.MovieDetails.overviewunavailable": "Sinopse indisponível.", "components.MovieDetails.overview": "Sinopse", "components.MovieDetails.originallanguage": "Língua original", "components.MovieDetails.manageModalTitle": "Gerenciar Filme", "components.MovieDetails.manageModalRequests": "Solicitações", "components.MovieDetails.manageModalNoRequests": "Nenhuma Solicitação", - "components.MovieDetails.manageModalClearMediaWarning": "Isso irá remover em definitivo todos dados de mídia, incluindo todas solicitações para esse item. Se este item existir in sua biblioteca do Plex, os dados de mídia serão recriados na próxima sincronia.", + "components.MovieDetails.manageModalClearMediaWarning": "Isso irá remover em definitivo todos dados deste filme, incluindo todas solicitações. Se este item existir em sua biblioteca do Plex, os dados de mídia serão recriados na próxima sincronia.", "components.MovieDetails.manageModalClearMedia": "Limpar Todos Dados de Mídia", "components.MovieDetails.decline": "Rejeitar", "components.MovieDetails.cast": "Elenco", @@ -43,7 +43,7 @@ "components.MovieDetails.approve": "Aprovar", "components.MovieDetails.MovieCast.fullcast": "Elenco Completo", "components.Login.signinplex": "Faça login para continuar", - "components.Layout.alphawarning": "Essa é uma versão Alpha. Quase tudo é instável ou não funciona. Por favor reporte os problemas no GitHub do Overseerr!", + "components.Layout.alphawarning": "Essa é uma versão Alpha. Algumas funcionalidades podem ser instáveis ou não funcionarem. Por favor reporte os problemas no GitHub!", "components.Layout.UserDropdown.signout": "Sair", "components.Layout.Sidebar.users": "Usuários", "components.Layout.Sidebar.settings": "Configurações", @@ -69,7 +69,7 @@ "components.RequestModal.cancelling": "Cancelando…", "components.Settings.plexlibraries": "Bibliotecas do Plex", "components.Settings.notrunning": "Parado", - "components.Settings.notificationsettingsDescription": "Aqui você pode escolher e selecionar os tipos de notificações e através de quais serviços deseja enviar.", + "components.Settings.notificationsettingsDescription": "Configuração global de notificações. As configurações abaixo afetam todos agentes de notificação.", "pages.pageNotFound": "404 - Página Não Encontrada", "components.RequestModal.season": "Temporada", "components.Settings.notificationsettings": "Configurações de Notificação", @@ -89,7 +89,7 @@ "components.Settings.librariesRemaining": "Bibliotecas Restantes: {count}", "components.Settings.jobname": "Nome da Tarefa", "components.Settings.hostname": "Nome de Servidor/IP", - "components.Settings.generalsettingsDescription": "Essas são configurações gerais do Overseerr.", + "components.Settings.generalsettingsDescription": "Defina configurações globais e padrões para o Overseerr.", "components.Settings.generalsettings": "Configurações Gerais", "components.Settings.edit": "Editar", "components.Settings.deleteserverconfirm": "Tem certeza que deseja apagar esse servidor?", @@ -100,7 +100,7 @@ "components.Settings.default4k": "Padrão 4K", "components.Settings.default": "Padrão", "components.Settings.currentlibrary": "Biblioteca Atual: {name}", - "components.Settings.copied": "Chave de API copiada", + "components.Settings.copied": "Chave de API copiada.", "components.Settings.cancelscan": "Cancelar Escaneamento", "components.Settings.applicationurl": "URL da Aplicação", "components.Settings.apikey": "Chave de API", @@ -109,28 +109,28 @@ "components.Settings.addradarr": "Adicionar Servidor Radarr", "components.Settings.activeProfile": "Perfil Ativo", "components.Settings.SonarrModal.validationRootFolderRequired": "Você deve selecionar uma pasta raíz", - "components.Settings.SonarrModal.validationProfileRequired": "Você deve selecionar um perfil", + "components.Settings.SonarrModal.validationProfileRequired": "Você deve selecionar um perfil de qualidade", "components.Settings.SonarrModal.validationPortRequired": "Você deve prover uma porta", "components.Settings.SonarrModal.validationNameRequired": "Você deve prover o nome do servidor", "components.Settings.SonarrModal.validationHostnameRequired": "Você deve prover o Nome do Servidor/IP", "components.Settings.SonarrModal.validationApiKeyRequired": "Você deve prover uma chave de API", "components.Settings.SonarrModal.toastRadarrTestSuccess": "Conexão estabelecida com servidor Sonarr!", "components.Settings.SonarrModal.toastRadarrTestFailure": "Falha ao conectar ao Servidor Sonarr", - "components.Settings.SonarrModal.testFirstRootFolders": "Teste sua conexão para carregar as pastas raízes", - "components.Settings.SonarrModal.testFirstQualityProfiles": "Teste sua conexão para carregar perfis de qualidade", + "components.Settings.SonarrModal.testFirstRootFolders": "Teste conexão para carregar as pastas raízes", + "components.Settings.SonarrModal.testFirstQualityProfiles": "Teste conexão para carregar perfis de qualidade", "components.Settings.SonarrModal.test": "Testar", "components.Settings.SonarrModal.ssl": "SSL", "components.Settings.SonarrModal.servernamePlaceholder": "Meu Servidor Sonarr", "components.Settings.SonarrModal.servername": "Nome do Servidor", "components.Settings.SonarrModal.server4k": "Servidor 4K", - "components.Settings.SonarrModal.selectRootFolder": "Selecione a Pasta Raíz", - "components.Settings.SonarrModal.selectQualityProfile": "Selecione o Perfil de Qualidade", + "components.Settings.SonarrModal.selectRootFolder": "Selecione a pasta raíz", + "components.Settings.SonarrModal.selectQualityProfile": "Selecione o perfil de qualidade", "components.Settings.SonarrModal.seasonfolders": "Temporadas Em Pastas", "components.Settings.SonarrModal.save": "Salvar Mudanças", "components.Settings.SonarrModal.rootfolder": "Pasta Raíz", "components.Settings.SonarrModal.qualityprofile": "Perfil de Qualidade", "components.Settings.SonarrModal.port": "Porta", - "components.Settings.SonarrModal.loadingrootfolders": "Carregando Pastas Raízes…", + "components.Settings.SonarrModal.loadingrootfolders": "Carregando pastas raízes…", "components.Settings.SonarrModal.loadingprofiles": "Carregando Perfis de Qualidade…", "components.Settings.SonarrModal.hostname": "Nome do Servidor / IP", "components.Settings.SonarrModal.editsonarr": "Editar Servidor Sonarr", @@ -138,7 +138,7 @@ "components.Settings.SonarrModal.createsonarr": "Criar Um Novo Servidor Sonarr", "components.Settings.SonarrModal.baseUrlPlaceholder": "Exemplo: /sonarr", "components.Settings.SonarrModal.baseUrl": "URL Base", - "components.Settings.SonarrModal.apiKeyPlaceholder": "Sua Chave de API do Sonarr", + "components.Settings.SonarrModal.apiKeyPlaceholder": "Sua chave de API do Sonarr", "components.Settings.SonarrModal.apiKey": "Chave de API", "components.Settings.SonarrModal.animerootfolder": "Pasta Raíz de Animes", "components.Settings.SonarrModal.animequalityprofile": "Perfil de Qualidade Para Animes", @@ -149,25 +149,25 @@ "components.Settings.SettingsAbout.overseerrinformation": "Sobre Overseerr", "components.Settings.SettingsAbout.githubdiscussions": "Discussões no GitHub", "components.Settings.SettingsAbout.gettingsupport": "Obtenha Suporte", - "components.Settings.SettingsAbout.clickheretojoindiscord": "Clique aqui para participar de nosso servidor Discord.", + "components.Settings.SettingsAbout.clickheretojoindiscord": "Clique aqui para participar de nosso servidor Discord!", "components.Settings.RadarrModal.validationRootFolderRequired": "Você deve selecionar uma pasta raíz", - "components.Settings.RadarrModal.validationProfileRequired": "Você deve selecionar um perfil", + "components.Settings.RadarrModal.validationProfileRequired": "Você deve selecionar um perfil de qualidade", "components.Settings.RadarrModal.validationPortRequired": "Você deve prover uma porta", "components.Settings.RadarrModal.validationNameRequired": "Você deve prover o nome do servidor", "components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "Você deve selecionar a disponibilidade mínima", "components.Settings.RadarrModal.validationHostnameRequired": "Você deve prover o Nome do Servidor/IP", "components.Settings.RadarrModal.validationApiKeyRequired": "Você deve prover uma chave de API", "components.Settings.RadarrModal.toastRadarrTestSuccess": "Conexão estabelecida com Radarr!", - "components.Settings.RadarrModal.toastRadarrTestFailure": "Falha ao conectar ao Servidor Radarr", - "components.Settings.RadarrModal.testFirstRootFolders": "Teste sua conexão para carregar as pastas raízes", - "components.Settings.RadarrModal.testFirstQualityProfiles": "Teste sua conexão para carregar perfis de qualidade", + "components.Settings.RadarrModal.toastRadarrTestFailure": "Falha ao conectar-se ao Radarr.", + "components.Settings.RadarrModal.testFirstRootFolders": "Teste conexão para carregar as pastas raízes", + "components.Settings.RadarrModal.testFirstQualityProfiles": "Teste conexão para carregar perfis de qualidade", "components.Settings.RadarrModal.test": "Testar", "components.Settings.RadarrModal.ssl": "SSL", "components.Settings.RadarrModal.servernamePlaceholder": "Meu Servidor Radarr", "components.Settings.RadarrModal.servername": "Nome do Servidor", "components.Settings.RadarrModal.server4k": "Servidor 4K", - "components.Settings.RadarrModal.selectRootFolder": "Selecione a Pasta Raíz", - "components.Settings.RadarrModal.selectQualityProfile": "Selecione o Perfil de Qualidade", + "components.Settings.RadarrModal.selectRootFolder": "Selecione a pasta raíz", + "components.Settings.RadarrModal.selectQualityProfile": "Selecione o perfil de qualidade", "components.Settings.RadarrModal.selectMinimumAvailability": "Selecione disponibilidade mínima", "components.Settings.RadarrModal.save": "Salvar Mudanças", "components.Settings.RadarrModal.rootfolder": "Pasta Raíz", @@ -198,11 +198,11 @@ "components.Settings.Notifications.emailsettingssaved": "Configurações de notificação via e-mail salvas!", "components.Settings.Notifications.emailsettingsfailed": "Falha ao salvar configurações de notificação via e-mail.", "components.Settings.Notifications.emailsender": "Email do Remetente", - "components.Settings.Notifications.discordsettingssaved": "Configurações de notificação do Discord salvas!", - "components.Settings.Notifications.discordsettingsfailed": "Falha ao salvar configurações de notificação do Discord.", - "components.Settings.Notifications.authUser": "Usuário", - "components.Settings.Notifications.authPass": "Senha", - "components.Settings.Notifications.agentenabled": "Agente Habilitado", + "components.Settings.Notifications.discordsettingssaved": "Configurações de notificação via Discord salvas!", + "components.Settings.Notifications.discordsettingsfailed": "Falha ao salvar configurações de notificação via Discord.", + "components.Settings.Notifications.authUser": "Usuário SMTP", + "components.Settings.Notifications.authPass": "Senha SMTP", + "components.Settings.Notifications.agentenabled": "Habilitar Agente", "components.Search.searchresults": "Resultados da Pesquisa", "components.TvDetails.status": "Estado", "components.RequestModal.status": "Estado", @@ -213,9 +213,9 @@ "components.RequestModal.requestfrom": "Existe uma solicitação pendende de {username}", "components.RequestModal.requestadmin": "Sua solicitação será aprovada imediatamente.", "components.RequestModal.requestSuccess": "{title} solicitado.", - "components.RequestModal.requestCancel": "Solicitação para {title} cancelada", + "components.RequestModal.requestCancel": "Solicitação para {title} cancelada.", "components.RequestModal.request": "Solicitar", - "components.RequestModal.pendingrequest": "Solicitação pendente para {title}", + "components.RequestModal.pendingrequest": "Solicitação Pendente para {title}", "components.RequestModal.numberofepisodes": "# de Episódeos", "components.RequestModal.notrequested": "Não Solicitado", "components.RequestModal.extras": "Extras", @@ -244,32 +244,32 @@ "components.Settings.ssl": "SSL", "components.Settings.sonarrsettings": "Configurações do Sonarr", "components.Settings.servernamePlaceholder": "Nome do Servidor Plex", - "components.Settings.servername": "Nome do Servidor (Automaticamente definido após você salvar)", + "components.Settings.servername": "Nome do Servidor", "components.Settings.saving": "Salvando…", "components.Settings.save": "Salvar Mudanças", "components.Settings.runnow": "Executar Agora", "components.Settings.radarrsettings": "Configurações do Radarr", "components.Settings.port": "Porta", - "components.Settings.plexsettingsDescription": "Configure os dados de conexão com servidor Plex. Overseerr irá escanear sua biblioteca em intervalos e checar por novo conteúdo.", + "components.Settings.plexsettingsDescription": "Configure os dados de conexão com servidor Plex. Overseerr escanea suas bibliotecas do Plex em busca de novo conteúdo disponível.", "components.Settings.plexsettings": "Configurações do Plex", - "components.Settings.plexlibrariesDescription": "Bibliotecas que Overseerr irá buscar por títulos. Configure e salve as informações de conexão com Plex e click no botão abaixo se nenhuma biblioteca é listada.", + "components.Settings.plexlibrariesDescription": "Bibliotecas que Overseerr irá buscar por títulos. Configure e salve as informações de conexão com Plex e clique no botão abaixo se nenhuma biblioteca é listada.", "components.Settings.SettingsAbout.timezone": "Fuso Horário", "components.Settings.SettingsAbout.helppaycoffee": "Ajude a Pagar o Café", "components.Settings.SettingsAbout.supportoverseerr": "Apoie o Overseerr", "components.Settings.SettingsAbout.Releases.viewongithub": "Ver no GitHub", "components.Settings.SettingsAbout.Releases.viewchangelog": "Ver Mudanças", "components.Settings.SettingsAbout.Releases.versionChangelog": "Mudanças Nessa Versão", - "components.Settings.SettingsAbout.Releases.runningDevelopMessage": "As mudanças em sua versão não serão exibidas abaixo. Por favor acesse o repositório do GitHub para saber o que mudou.", + "components.Settings.SettingsAbout.Releases.runningDevelopMessage": "As mudanças em sua versão não serão exibidas abaixo. Por favor acesse o repositório do GitHub para saber o que mudou.", "components.Settings.SettingsAbout.Releases.runningDevelop": "Você está usando uma versão de desenvolvimento do Overseerr!", "components.Settings.SettingsAbout.Releases.releases": "Versões", - "components.Settings.SettingsAbout.Releases.releasedataMissing": "Informações de versão faltando. O GitHub está indisponível?", + "components.Settings.SettingsAbout.Releases.releasedataMissing": "Informações de versão indisponíveis. O GitHub está indisponível?", "components.Settings.SettingsAbout.Releases.latestversion": "Última Versão", "components.Settings.SettingsAbout.Releases.currentversion": "Versão Atual", "components.TvDetails.request": "Solicitar", "components.TvDetails.recommendationssubtext": "Se você gostou de {title}, você provavelmente irá gostar…", "components.TvDetails.recommendations": "Recomendações", "components.TvDetails.pending": "Pendente", - "components.TvDetails.overviewunavailable": "Sinopse indisponível", + "components.TvDetails.overviewunavailable": "Sinopse indisponível.", "components.TvDetails.overview": "Sinopse", "components.TvDetails.originallanguage": "Língua original", "components.TvDetails.network": "Estúdio", @@ -288,20 +288,20 @@ "components.TvDetails.TvCast.fullseriescast": "Elenco Completo da Série", "components.TitleCard.tvshow": "Série", "components.TitleCard.movie": "Filme", - "components.Slider.noresults": "Nenhum Resultado", + "components.Slider.noresults": "Nenhum resultado.", "components.Setup.welcome": "Bem-Vindo ao Overseerr", "components.Setup.tip": "Dica", "components.Setup.syncingbackground": "A sincronização será executada em segundo plano. Você pode continuar a configuração enquanto isso.", - "components.Setup.signinMessage": "Comece fazendo login com sua conta Plex", + "components.Setup.signinMessage": "Comece entrando com sua conta Plex", "components.Setup.loginwithplex": "Entrar com Plex", "components.Setup.finishing": "Finalizando…", "components.Setup.finish": "Finalizar Configurações", "components.TvDetails.requestmore": "Solicitar Mais", "pages.somethingWentWrong": "{statusCode} - Algo deu errado", - "pages.serviceUnavailable": "{statusCode} - Serviço Indisponível", + "pages.serviceUnavailable": "{statusCode} - Serviço indisponível", "pages.returnHome": "Voltar Para Página Inicial", "pages.oops": "Opa", - "pages.internalServerError": "{statusCode} - Erro Interno no Servidor", + "pages.internalServerError": "{statusCode} - Erro interno no servidor", "i18n.unavailable": "Indisponível", "i18n.tvshows": "Séries", "i18n.processing": "Processando…", @@ -318,8 +318,8 @@ "components.UserList.usertype": "Tipo de Usuário", "components.UserList.username": "Usuário", "components.UserList.userlist": "Lista de Usuários", - "components.UserList.userdeleteerror": "Algo deu errado ao apagar usuário", - "components.UserList.userdeleted": "Usuário deletado", + "components.UserList.userdeleteerror": "Algo deu errado ao remover usuário.", + "components.UserList.userdeleted": "Usuário removido.", "components.UserList.user": "Usuário", "components.UserList.totalrequests": "Total de Solicitações", "components.UserList.role": "Privilégio", @@ -332,10 +332,10 @@ "components.UserList.admin": "Administrador", "components.UserEdit.voteDescription": "Concede permissão para votar em solicitações (sistema de votos ainda não implementado)", "components.UserEdit.vote": "Votar", - "components.UserEdit.usersaved": "Usuário salvo", + "components.UserEdit.usersaved": "Usuário salvo!", "components.UserEdit.usersDescription": "Concede permissão para gerenciar usuários do Overseerr. Usuários com essa permissão não conseguem modificar usuários de Adminitradores ou conceder esse privilégio .", "components.UserEdit.users": "Gerenciar Usuários", - "components.UserEdit.username": "Usuário", + "components.UserEdit.username": "Nome de Exibição", "components.UserEdit.userfail": "Algo deu errado ao salvar edições do usuário.", "components.UserEdit.settingsDescription": "Concede permissão para modificar todas configurações do Overseerr. Um usuário precisa dessa permissão para concedê-la para outros usuários.", "components.UserEdit.settings": "Gerenciar Configurações", @@ -358,9 +358,9 @@ "components.TvDetails.similarsubtext": "Outras séries similares a {title}", "components.TvDetails.similar": "Séries Similares", "components.TvDetails.showtype": "Categoria", - "components.TvDetails.manageModalClearMediaWarning": "Isso irá remover em definitivo todos dados de mídia, incluindo todas solicitações para esse item. Se este item existir in sua biblioteca do Plex, os dados de mídia serão recriados na próxima sincronia.", - "components.Settings.sonarrSettingsDescription": "Configure abaixo sua conexão com Sonarr. Você pode criar várias conexões, mas apenas duas por vez como padrão (uma para padrão HD, e outra para 4K). Administradores podem alterar qual conexão será usada para novas solicitações.", - "components.Settings.radarrSettingsDescription": "Configure abaixo sua conexão com Radarr. Você pode criar várias conexões, mas apenas duas por vez como padrão (uma para padrão HD, e outra para 4K). Administradores podem alterar qual conexão será usada para novas solicitações.", + "components.TvDetails.manageModalClearMediaWarning": "Isso irá remover em definitivo todos dados desta série, incluindo todas solicitações. Se este item existir em sua biblioteca do Plex, as informações de mídia serão recriadas na próxima sincronia.", + "components.Settings.sonarrSettingsDescription": "Configure abaixo sua conexão com Sonarr. Você pode criar várias conexões, mas apenas duas por vez como padrão (uma para padrão HD, e outra para 4K). Administradores podem alterar o servidor usado para novas solicitações.", + "components.Settings.radarrSettingsDescription": "Configure abaixo sua conexão com Radarr. Você pode criar várias conexões, mas apenas duas por vez como padrão (uma para padrão HD, e outra para 4K). Administradores podem alterar o servidor usado para novas solicitações.", "components.Settings.Notifications.testsent": "Notificação de teste enviada!", "components.Settings.Notifications.test": "Testar", "components.RequestModal.requestseasons": "Solicitar {seasonCount} {seasonCount, plural, one {Temporada} other {Temporadas}}", @@ -369,15 +369,15 @@ "components.PersonDetails.crewmember": "Membro da Equipe Técnica", "components.MovieDetails.viewfullcrew": "Ver Equipe Técnica Completa", "components.MovieDetails.MovieCrew.fullcrew": "Equipe Técnica Completa", - "components.UserList.importfromplexerror": "Algo deu errado ao importar usuários do Plex", + "components.UserList.importfromplexerror": "Algo deu errado ao importar usuários do Plex.", "components.UserList.importfromplex": "Importar Usuários do Plex", "components.UserList.importedfromplex": "{userCount, plural, =0 {Nenhum novo usuário} one {# novo usuário} other {# novos usuários}} importado(s) do Plex", "components.Settings.defaultPermissions": "Permissões Padrões de Usúarios", "components.Settings.Notifications.NotificationsSlack.settingupslack": "Configurando Notificações Via Slack", - "components.Settings.Notifications.NotificationsSlack.saving": "Salvando...", + "components.Settings.Notifications.NotificationsSlack.saving": "Salvando…", "components.Settings.Notifications.NotificationsSlack.save": "Salvar Mudanças", - "components.Settings.Notifications.NotificationsSlack.agentenabled": "Agente Habilitado", - "components.RequestList.RequestItem.failedretry": "Algo deu errado ao retentar fazer a solicitação", + "components.Settings.Notifications.NotificationsSlack.agentenabled": "Habilitar Agente", + "components.RequestList.RequestItem.failedretry": "Algo deu errado ao retentar fazer a solicitação.", "components.MovieDetails.watchtrailer": "Assistir Trailer", "components.MovieDetails.view": "Ver", "components.CollectionDetails.requestswillbecreated": "Serão feitas solitações para os seguintes títulos:", @@ -385,7 +385,7 @@ "components.CollectionDetails.requestcollection": "Solicitar Coleção", "components.CollectionDetails.requestSuccess": "{title} solictiado com sucesso!", "components.CollectionDetails.request": "Solicitar", - "components.CollectionDetails.overviewunavailable": "Sinopse indisponível", + "components.CollectionDetails.overviewunavailable": "Sinopse indisponível.", "components.CollectionDetails.overview": "Sinopse", "components.CollectionDetails.numberofmovies": "Número de Filmes: {count}", "components.CollectionDetails.movies": "Filmes", @@ -398,14 +398,14 @@ "components.UserEdit.autoapproveMovies": "Aprovar Filmes Automaticamente", "components.TvDetails.watchtrailer": "Assisitir Trailer", "components.TvDetails.firstAirDate": "Primeira Exibição", - "components.Settings.Notifications.validationChatIdRequired": "Você deve prover um ID de Chat.", - "components.Settings.Notifications.validationBotAPIRequired": "Você deve prover a chave de API do Bot.", + "components.Settings.Notifications.validationChatIdRequired": "Você deve prover um ID de Chat", + "components.Settings.Notifications.validationBotAPIRequired": "Você deve prover uma chave de API do Bot", "components.Settings.Notifications.senderName": "Nome do Remetente", "components.Settings.Notifications.telegramsettingssaved": "Configurações de notificação via Telegram salvas!", "components.Settings.Notifications.telegramsettingsfailed": "Falha ao salvar configurações de notificação via Telegram.", - "components.Settings.Notifications.ssldisabletip": "SSL deve ser desabilitado em conexões TLS padrão (Porta 587)", - "components.Settings.Notifications.settinguptelegramDescription": "Para configurar notificações via Telegram, você precisa criar um bot e obter a chave de API do mesmo. Além disso, você irá precisar do ID de Chat de onde você deseja que o bot envie as notificações. Você pode obter o ID de Chat adicionando @get_id_bot ao chat ou grupo ao qual você deseja obter o ID.", - "components.Settings.Notifications.settinguptelegram": "Configurando notificações via Telegram", + "components.Settings.Notifications.ssldisabletip": "SSL deve ser desabilitado em conexões TLS padrão (porta 587)", + "components.Settings.Notifications.settinguptelegramDescription": "Para configurar notificações via Telegram, você precisa criar um bot e obter a chave de API do mesmo. Além disso, você irá precisar do ID de Chat de onde você deseja que o bot envie as notificações. Você pode obter o ID de Chat adicionando @get_id_bot ao chat ou grupo ao qual você deseja obter o ID.", + "components.Settings.Notifications.settinguptelegram": "Configurando Notificações Via Telegram", "components.Settings.Notifications.chatId": "ID de Chat", "components.Settings.Notifications.botAPI": "API do Bot", "components.Settings.Notifications.allowselfsigned": "Permitir certificados auto-assinados", @@ -416,7 +416,7 @@ "components.Settings.Notifications.NotificationsSlack.test": "Testar", "components.Settings.Notifications.NotificationsSlack.slacksettingssaved": "Configurações de notificação via Slack salvas!", "components.Settings.Notifications.NotificationsSlack.slacksettingsfailed": "Falha ao salvar configurações de notificação via Slack.", - "components.Settings.Notifications.NotificationsSlack.settingupslackDescription": "Para usar notificações via Slack você irá p recisar criar uma integração Webhook de entrada e usar no campo abaixo a URL gerada.", + "components.Settings.Notifications.NotificationsSlack.settingupslackDescription": "Para usar notificações via Slack você irá precisar criar uma integração Webhook de entrada e usar no campo abaixo a URL gerada.", "components.StatusChacker.reloadOverseerr": "Reiniciar Overseerr", "components.StatusChacker.newversionDescription": "Uma atualização está disponível. Clique no botão abaixo para reiniciar a aplicação.", "components.StatusChacker.newversionavailable": "Nova Versão Disponível", @@ -432,24 +432,24 @@ "components.NotificationTypeSelector.mediaavailableDescription": "Envia uma notificação quando o título solicitado estiver disponível.", "components.NotificationTypeSelector.mediaapprovedDescription": "Envia uma notificação quando a solicitação é aprovada.", "i18n.request": "Solicitar", - "components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "Você deve prover uma chave de acesso do usúario.", - "components.Settings.Notifications.NotificationsPushover.validationAccessTokenRequired": "Você deve prover uma chave de acesso.", + "components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "Você deve prover uma chave de acesso do usúario", + "components.Settings.Notifications.NotificationsPushover.validationAccessTokenRequired": "Você deve prover uma chave de acesso", "components.Settings.Notifications.NotificationsPushover.userToken": "Chave do Usuário", "components.Settings.Notifications.NotificationsPushover.testsent": "Notificação de teste enviada!", "components.Settings.Notifications.NotificationsPushover.test": "Testar", - "components.Settings.Notifications.NotificationsPushover.settinguppushoverDescription": "Para configurar notificações via Pushover, você precisa de registrar um aplicativo e obter a chave de acesso. Quando estiver configurando o aplicativo você pode usar on dos ícones no diretório público do GitHub. Você também precisa da chave de acesso que pode ser encontrada na página inicial do usuário Pushover.", + "components.Settings.Notifications.NotificationsPushover.settinguppushoverDescription": "Para configurar notificações via Pushover, você precisa de registrar um aplicativo e obter a chave de acesso. Quando estiver configurando o aplicativo, você pode usar um dos ícones no diretório público do GitHub. Você também precisa da chave de acesso que pode ser encontrada na página inicial do usuário Pushover.", "components.Settings.Notifications.NotificationsPushover.settinguppushover": "Configurando Notificações Via Pushover", - "components.Settings.Notifications.NotificationsPushover.saving": "Salvando...", + "components.Settings.Notifications.NotificationsPushover.saving": "Salvando…", "components.Settings.Notifications.NotificationsPushover.save": "Salvar Mudanças", "components.Settings.Notifications.NotificationsPushover.pushoversettingssaved": "Configurações de notificação via Pushover salvas!", "components.Settings.Notifications.NotificationsPushover.pushoversettingsfailed": "Falha ao salvar configurações de notificação via Pushover.", "components.Settings.Notifications.NotificationsPushover.notificationtypes": "Tipos de Notificação", - "components.Settings.Notifications.NotificationsPushover.agentenabled": "Agente Habilitado", + "components.Settings.Notifications.NotificationsPushover.agentenabled": "Habilitar Agente", "components.Settings.Notifications.NotificationsPushover.accessToken": "Token de Acesso", "components.RequestList.sortModified": "Última Mudança", "components.RequestList.sortAdded": "Data de Solicitação", "components.RequestList.showallrequests": "Exibir Todas Solicitações", - "components.RequestList.noresults": "Nenhum Resultado.", + "components.RequestList.noresults": "Nenhum resultado.", "components.RequestList.filterPending": "Pendentes", "components.RequestList.filterApproved": "Aprovadas", "components.RequestList.filterAll": "Todas", @@ -461,9 +461,9 @@ "components.UserEdit.request4k": "Solicitar 4K", "components.StatusBadge.status4k": "4K {status}", "components.RequestModal.request4ktitle": "Solicitar {title} em 4K", - "components.RequestModal.request4kfrom": "Existe uma solicitação em 4K pendente de {username}", + "components.RequestModal.request4kfrom": "Existe uma solicitação em 4K pendente de {username}.", "components.RequestModal.request4k": "Solicitar 4K", - "components.RequestModal.pending4krequest": "Solicitação em 4K pendente para {title}", + "components.RequestModal.pending4krequest": "Solicitação em 4K Pendente para {title}", "components.MovieDetails.RequestButton.viewrequest4k": "Ver Solicitação 4K", "components.MovieDetails.RequestButton.viewrequest": "Ver Solicitação", "components.MovieDetails.RequestButton.requestmore4k": "Solicitar Mais 4K", @@ -487,14 +487,14 @@ "components.Settings.Notifications.NotificationsWebhook.testsent": "Notificação de teste enviada!", "components.Settings.Notifications.NotificationsWebhook.test": "Testar", "components.Settings.Notifications.NotificationsWebhook.templatevariablehelp": "Ajuda Com Modelos de Variáveis", - "components.Settings.Notifications.NotificationsWebhook.saving": "Salvando...", + "components.Settings.Notifications.NotificationsWebhook.saving": "Salvando…", "components.Settings.Notifications.NotificationsWebhook.save": "Salvar Mudanças", - "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON restaurado para padrão.", - "components.Settings.Notifications.NotificationsWebhook.resetPayload": "Restaurar Conteúdo Padrão do JSON", + "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON restaurado para conteúdo padrão.", + "components.Settings.Notifications.NotificationsWebhook.resetPayload": "Restaurar Padrão", "components.Settings.Notifications.NotificationsWebhook.notificationtypes": "Tipos de Notificação", "components.Settings.Notifications.NotificationsWebhook.customJson": "Conteúdo JSON Personalizado", "components.Settings.Notifications.NotificationsWebhook.authheader": "Cabeçalho de Autorização", - "components.Settings.Notifications.NotificationsWebhook.agentenabled": "Agente Habilitado", + "components.Settings.Notifications.NotificationsWebhook.agentenabled": "Habilitar Agente", "components.RequestButton.viewrequest4k": "Ver Solicitação 4K", "components.RequestButton.viewrequest": "Ver Solicitação", "components.RequestButton.requestmore4k": "Solicitar Mais 4K", @@ -509,11 +509,11 @@ "components.RequestButton.approverequest4k": "Aprovar Solicitação 4K", "components.RequestButton.approverequest": "Aprovar Solicitação", "components.RequestButton.approve4krequests": "Aprovar {requestCount} {requestCount, plural, one {Solicitação} other {Solicitações}} 4K", - "components.UserList.validationpasswordminchars": "Senha muito curta - necessário ter no mínimo 8 caracteres.", - "components.UserList.validationemailrequired": "É necessário um e-mail válido.", - "components.UserList.usercreatedsuccess": "Usuário criado com sucesso", - "components.UserList.usercreatedfailed": "Algo deu errado ao tentar criar usuário", - "components.UserList.passwordinfodescription": "Para usar a geração automática de senhas, é necessário que as configurações de notificação via e-mail estejam ativas", + "components.UserList.validationpasswordminchars": "Senha muito curta; necessário ter no mínimo 8 caracteres", + "components.UserList.validationemailrequired": "É necessário um e-mail válido", + "components.UserList.usercreatedsuccess": "Usuário criado com sucesso!", + "components.UserList.usercreatedfailed": "Algo deu errado ao criar usuário.", + "components.UserList.passwordinfodescription": "Para usar a geração automática de senhas, é necessário que as configurações de notificação via e-mail estejam ativas.", "components.UserList.passwordinfo": "Informações de Senha", "components.UserList.password": "Senha", "components.UserList.localuser": "Usuário Local", @@ -525,11 +525,139 @@ "components.UserList.autogeneratepassword": "Gerar senha automaticamente", "components.Login.validationpasswordrequired": "Senha necessária", "components.Login.validationemailrequired": "E-mail inválido", - "components.Login.signinwithoverseerr": "Entrar com Overseerr", + "components.Login.signinwithoverseerr": "Entrar com sua conta Overseerr", "components.Login.password": "Senha", - "components.Login.loginerror": "Algo deu errado ao tentar se autenticar", + "components.Login.loginerror": "Algo deu errado ao tentar se autenticar.", "components.Login.login": "Entrar", - "components.Login.loggingin": "Autenticando...", + "components.Login.loggingin": "Autenticando…", "components.Login.goback": "Voltar", - "components.Login.email": "Endereço de E-mail" + "components.Login.email": "Endereço de E-mail", + "components.NotificationTypeSelector.mediadeclinedDescription": "Envia uma notificação quando uma solicitação é recusada.", + "components.NotificationTypeSelector.mediadeclined": "Mídia Recusada", + "components.MediaSlider.ShowMoreCard.seemore": "Ver Mais", + "components.RequestModal.requestedited": "Solicitação modificada.", + "components.RequestModal.requestcancelled": "Solicitação cancelada.", + "components.RequestModal.errorediting": "Algo deu errado ao modificar a solicitação.", + "components.RequestModal.autoapproval": "Aprovação Automática", + "components.RequestModal.AdvancedRequester.rootfolder": "Pasta Raiz", + "components.RequestModal.AdvancedRequester.qualityprofile": "Perfil de Qualidade", + "components.RequestModal.AdvancedRequester.loadingprofiles": "Carregando perfis…", + "components.RequestModal.AdvancedRequester.loadingfolders": "Carregando pastas…", + "components.RequestModal.AdvancedRequester.destinationserver": "Servidor de destino", + "components.RequestModal.AdvancedRequester.default": "(Padrão)", + "components.RequestModal.AdvancedRequester.animenote": "* Esta série é um anime.", + "components.RequestModal.AdvancedRequester.advancedoptions": "Opções Avançadas", + "components.RequestBlock.server": "Servidor", + "components.RequestBlock.rootfolder": "Pasta Raiz", + "components.RequestBlock.requestoverrides": "Mudanças na solicitação", + "components.RequestBlock.profilechanged": "Perfil Alterado", + "i18n.edit": "Editar", + "components.UserEdit.advancedrequestDescription": "Concede permissão para usar opções de solicitações avançadas. (Ex. Alterar servidores / perfis / caminhos)", + "components.UserEdit.advancedrequest": "Solicitações Avançadas", + "i18n.experimental": "Experimental", + "components.Settings.hideAvailable": "Ocultar Títulos Disponíveis", + "components.RequestModal.requesterror": "Algo deu errado ao solicitar mídia.", + "components.RequestModal.notvdbiddescription": "Adicione o ID TVDB ao TMDb e tente outra vez mais tarde, ou selecione a correspondência correta abaixo:", + "components.RequestModal.notvdbid": "ID do TVDB não encontrado para este item no TMDb.", + "components.RequestModal.SearchByNameModal.notvdbiddescription": "Não conseguimos correlacionar sua solicitação automaticamente. Por favor selecione a correspondência correta na lista abaixo:", + "components.RequestModal.SearchByNameModal.notvdbid": "Correlação Manual Necessária", + "components.RequestModal.next": "Próxima", + "components.RequestModal.backbutton": "Voltar", + "components.RequestModal.SearchByNameModal.nosummary": "Sinopse não encontrada para esse título.", + "components.RequestModal.SearchByNameModal.next": "Próxima", + "components.Login.signin": "Entrar", + "components.UserList.userssaved": "Usuários salvos!", + "components.UserList.bulkedit": "Edição Em Massa", + "components.Settings.toastPlexRefreshSuccess": "Lista de servidores obtida do Plex.", + "components.Settings.toastPlexRefreshFailure": "Não foi possível obter a lista de servidores do Plex!", + "components.Settings.toastPlexRefresh": "Obtendo lista de servidores do Plex…", + "components.Settings.toastPlexConnectingSuccess": "Conectado ao servidor Plex.", + "components.Settings.toastPlexConnectingFailure": "Não foi possível se conectar ao Plex!", + "components.Settings.toastPlexConnecting": "Tentando se conectar ao Plex…", + "components.Settings.timeout": "Tempo limite excedido", + "components.Settings.settingUpPlexDescription": "Para configurar o Plex, você pode entrar com as configurações manualmente ou escolher um dos servidores disponívies obtivos de plex.tv. Clique no botão próximo à lista para atualizar e checar a conectividade com o servidor.", + "components.Settings.settingUpPlex": "Configurando Plex", + "components.Settings.serverpresetRefreshing": "Obtendo servidores…", + "components.Settings.serverpresetPlaceholder": "Servidor Plex", + "components.Settings.serverpresetManualMessage": "Configurar manualmente", + "components.Settings.serverpresetLoad": "Clique para carregar servidores disponíveis", + "components.Settings.serverpreset": "Servidor", + "components.Settings.serverRemote": "remoto", + "components.Settings.serverLocal": "local", + "components.Settings.serverConnected": "conectado", + "components.Settings.notificationsettingssaved": "Configurações de notificação salvas!", + "components.Settings.notificationsettingsfailed": "Falha ao salvar configurações de notificação.", + "components.Settings.notificationAgentsSettings": "Agentes de Notificação", + "components.Settings.notificationAgentSettingsDescription": "Escolha os tipos de notificações a enviar e quais agentes de notificação usar.", + "components.Settings.ms": "ms", + "components.Settings.enablenotifications": "Habilitar Notificações", + "components.Settings.csrfProtectionTip": "Definir acesso externo à API como apenas leitura (É necessário reiniciar Overseerr para mudança ter efeito)", + "components.Settings.csrfProtection": "Habilitar Proteção Contra CSRF", + "components.Settings.autoapprovedrequests": "Enviar Notificações para Solicitações Aprovadas Automaticamente", + "components.PlexLoginButton.signinwithplex": "Entrar", + "components.Login.signingin": "Autenticando…", + "components.PlexLoginButton.signingin": "Autenticando…", + "components.PermissionEdit.voteDescription": "Concede permissão para votar em solicitações (sistema de votos ainda não implementado)", + "components.PermissionEdit.vote": "Votar", + "components.PermissionEdit.usersDescription": "Concede permissão para gerenciar usuários do Overseerr. Usuários com essa permissão não podem modificar usuários com acesso Administrativo, ou condecer tal permissão.", + "components.PermissionEdit.users": "Gerenciar Usuários", + "components.PermissionEdit.settingsDescription": "Concede permissão para modificar todas configurações do Overseerr. O usuário precisar ter essa permissão para concedê-la a outros.", + "components.PermissionEdit.settings": "Gerenciar Configurações", + "components.PermissionEdit.requestDescription": "Concede permissão para solicitar filmes e séries.", + "components.PermissionEdit.request4kTvDescription": "Concede permissão para solicitar séries em 4K.", + "components.PermissionEdit.request4kTv": "Solicitar Séries em 4K", + "components.PermissionEdit.request4kMoviesDescription": "Concede permissão para solicitar filmes em 4K.", + "components.PermissionEdit.request4kMovies": "Solicitar Filmes 4K", + "components.PermissionEdit.request4kDescription": "Concede permissão para solicitar filmes e séries em 4K.", + "components.PermissionEdit.request4k": "Solicitar 4K", + "components.PermissionEdit.request": "Solicitar", + "components.PermissionEdit.managerequestsDescription": "Concede permissão para gerenciar solicitações no Overseerr. Isso inclui aprovar e negar solicitações.", + "components.PermissionEdit.managerequests": "Gerenciar Solicitações", + "components.PermissionEdit.autoapproveSeriesDescription": "Concede aprovação automática para solicitações de séries feitas por esse usuário.", + "components.PermissionEdit.autoapproveSeries": "Aprovar Séries Automaticamente", + "components.PermissionEdit.autoapproveMoviesDescription": "Concede aprovação automática para solicitações de filmes feitas por esse usuário.", + "components.PermissionEdit.autoapproveMovies": "Aprovar Filmes Automaticamente", + "components.PermissionEdit.autoapproveDescription": "Concede aprovação automática para todas solicitações feitas por esse usuário.", + "components.PermissionEdit.autoapprove": "Aprovar Automaticamente", + "components.PermissionEdit.advancedrequestDescription": "Concede permissão para fazer solicitações avançadas; ex. escolher servidores, perfis ou caminhos.", + "components.PermissionEdit.advancedrequest": "Solicitações Avançadas", + "components.PermissionEdit.adminDescription": "Acesso adminsitrativo completo. Ignora todas checagens de privilégios.", + "components.PermissionEdit.admin": "Administrador", + "components.Login.signinwithplex": "Entrar com sua conta Plex", + "components.Login.signinheader": "Autentique para continuar", + "components.Settings.servernameTip": "Obtido automaticamente do Plex após salvar", + "components.Settings.SonarrModal.toastSonarrTestSuccess": "Conexão com Sonarr estabelecida!", + "components.Settings.SonarrModal.toastSonarrTestFailure": "Falha ao conectar-se ao Sonarr.", + "components.Common.ListView.noresults": "Nenhum resultado.", + "components.UserEdit.plexUsername": "Usuário Plex", + "components.TvDetails.opensonarr4k": "Abrir série no Sonarr 4K", + "components.TvDetails.opensonarr": "Abrir série no Sonarr", + "components.TvDetails.downloadstatus": "Estado do Download", + "components.TvDetails.areyousure": "Tem certeza?", + "components.Settings.jobtype": "Tipo", + "components.Settings.jobcancelled": "{jobname} cancelado(a).", + "components.Settings.jobstarted": "{jobname} iniciado(a).", + "components.Settings.canceljob": "Cancelar Tarefa", + "components.Settings.SonarrModal.syncEnabled": "Habilitar Sincronização", + "components.Settings.SonarrModal.preventSearch": "Desabilitar Busca Automática", + "components.Settings.SonarrModal.externalUrlPlaceholder": "URL Externa do Sonarr", + "components.Settings.SonarrModal.externalUrl": "URL Externa", + "components.Settings.RadarrModal.syncEnabled": "Habilitar Sincronização", + "components.Settings.RadarrModal.preventSearch": "Desabilitar Busca Automática", + "components.Settings.RadarrModal.externalUrlPlaceholder": "URL externa do Radarr", + "components.Settings.RadarrModal.externalUrl": "URL Externa", + "components.MovieDetails.openradarr4k": "Abrir Filme no Radarr 4K", + "components.MovieDetails.openradarr": "Abrir Filme no Radarr", + "components.MovieDetails.downloadstatus": "Estado do Download", + "components.MovieDetails.areyousure": "Tem certeza?", + "components.TvDetails.playonplex": "Assitir no Plex", + "components.TvDetails.play4konplex": "Assistir em 4K no Plex", + "components.MovieDetails.playonplex": "Assistir no Plex", + "components.MovieDetails.play4konplex": "Assistir em 4K no Plex", + "components.TvDetails.markavailable": "Marcar como Disponível", + "components.TvDetails.mark4kavailable": "Marcar como Disponível em 4K", + "components.TvDetails.allseasonsmarkedavailable": "* Todas temporadas serão marcadas como disponíveis.", + "components.MovieDetails.markavailable": "Marcar como Disponível", + "components.MovieDetails.mark4kavailable": "Marcar como Disponível em 4K", + "components.Settings.trustProxy": "Habilitar Suporte a Proxy" } diff --git a/src/i18n/locale/pt_PT.json b/src/i18n/locale/pt_PT.json new file mode 100644 index 00000000..e1620cc9 --- /dev/null +++ b/src/i18n/locale/pt_PT.json @@ -0,0 +1,636 @@ +{ + "components.CollectionDetails.movies": "Filmes", + "components.Settings.SonarrModal.testing": "Testando…", + "components.Settings.RadarrModal.testing": "Testando…", + "components.Settings.RadarrModal.testFirstRootFolders": "Teste conexão para carregar as pastas raízes", + "components.Settings.RadarrModal.testFirstQualityProfiles": "Teste conexão para carregar perfis de qualidade", + "components.Settings.RadarrModal.ssl": "SSL", + "components.Settings.RadarrModal.servernamePlaceholder": "Meu Servidor Radarr", + "components.Settings.SonarrModal.servername": "Nome do Servidor", + "components.Settings.SonarrModal.server4k": "Servidor 4K", + "components.Settings.RadarrModal.server4k": "Servidor 4K", + "components.Settings.SonarrModal.selectRootFolder": "Selecione a pasta raíz", + "components.Settings.RadarrModal.selectRootFolder": "Selecione a pasta raíz", + "components.Settings.SonarrModal.selectQualityProfile": "Selecione o perfil de qualidade", + "components.Settings.RadarrModal.selectQualityProfile": "Selecione o perfil de qualidade", + "components.Settings.RadarrModal.selectMinimumAvailability": "Selecione disponibilidade mínima", + "components.Settings.SonarrModal.rootfolder": "Pasta Raíz", + "components.Settings.RadarrModal.rootfolder": "Pasta Raíz", + "components.Settings.RadarrModal.qualityprofile": "Perfil de Qualidade", + "components.Settings.RadarrModal.port": "Porta", + "components.Settings.RadarrModal.minimumAvailability": "Disponibilidade Mínima", + "components.Settings.RadarrModal.loadingrootfolders": "Carregando Pastas Raízes…", + "components.Settings.RadarrModal.loadingprofiles": "Carregando Perfis de Qualidade…", + "components.Settings.RadarrModal.hostname": "Nome do host / IP", + "components.Settings.RadarrModal.servername": "Nome do Servidor", + "components.Settings.RadarrModal.editradarr": "Modificar Servidor Radarr", + "components.Settings.RadarrModal.defaultserver": "Servidor Padrão", + "components.Settings.RadarrModal.createradarr": "Criar Um Novo Servidor Radarr", + "components.Settings.RadarrModal.baseUrlPlaceholder": "Exemplo: /radarr", + "components.Settings.RadarrModal.baseUrl": "URL Base", + "components.Settings.RadarrModal.apiKeyPlaceholder": "Sua chave API do Radarr", + "components.Settings.RadarrModal.apiKey": "Chave API", + "components.Settings.RadarrModal.add": "Adicionar Servidor", + "components.Settings.Notifications.webhookUrlPlaceholder": "Configurações do Servidor → Integrações → Webhooks", + "components.Settings.Notifications.validationSmtpPortRequired": "Você deve fornecer a porta SMTP", + "components.Settings.Notifications.validationSmtpHostRequired": "Você deve fornecer um servidor SMTP", + "components.Settings.Notifications.validationFromRequired": "Você deve fornecer um endereço do remetente", + "components.Settings.Notifications.validationChatIdRequired": "Você deve fornecer um ID de Chat", + "components.Settings.Notifications.validationBotAPIRequired": "Você deve fornecer uma chave API do Bot", + "components.Settings.Notifications.telegramsettingssaved": "Configurações de notificação via Telegram salvadas!", + "components.Settings.Notifications.telegramsettingsfailed": "Falhou o salvar das configurações de notificação via Telegram.", + "components.Settings.Notifications.ssldisabletip": "SSL deve ser desabilitado em conexões TLS padrões (porta 587)", + "components.Settings.Notifications.smtpPort": "Porta SMTP", + "components.Settings.Notifications.smtpHost": "Servidor SMTP", + "components.Settings.Notifications.settinguptelegramDescription": "Para configurar notificações via Telegram, você precisa criar um bot e obter a chave API do mesmo. Além disso, você precisará do ID de Chat de onde você deseja que o bot envie as notificações. Você pode obter o ID de Chat adicionando @get_id_bot ao chat ou grupo ao qual você deseja obter o ID.", + "components.Settings.Notifications.settinguptelegram": "Configurando notificações via Telegram", + "components.Settings.Notifications.senderName": "Nome do Remetente", + "components.Settings.Notifications.enableSsl": "Activar SSL", + "components.Settings.Notifications.emailsettingssaved": "Configurações de notificação via e-mail salvadas!", + "components.Settings.Notifications.discordsettingssaved": "Configurações de notificação via Discord salvadas!", + "components.Settings.Notifications.discordsettingsfailed": "Falhou o salvar das configurações de notificação via Discord.", + "components.Settings.Notifications.emailsettingsfailed": "Falhou o salvar das configurações de notificação via e-mail.", + "components.Settings.Notifications.emailsender": "Endereço do remetente", + "components.Settings.Notifications.chatId": "ID do Chat", + "components.Settings.Notifications.botAPI": "API do Bot", + "components.Settings.Notifications.authUser": "Utilizador SMTP", + "components.Settings.Notifications.authPass": "Palavra-passe SMTP", + "components.Settings.Notifications.allowselfsigned": "Permitir certificados auto-assinados", + "components.Settings.Notifications.NotificationsWebhook.webhooksettingssaved": "Configurações de notificação via Webhook salvadas!", + "components.Settings.Notifications.NotificationsWebhook.webhooksettingsfailed": "Falhou o salvar das configurações de notificação via Webhook.", + "components.Settings.Notifications.NotificationsWebhook.webhookUrlPlaceholder": "URL remota de webhook", + "components.Settings.Notifications.validationWebhookUrlRequired": "Você deve fornecer uma URL de webhook", + "components.Settings.Notifications.NotificationsSlack.validationWebhookUrlRequired": "Você deve fornecer uma URL de webhook", + "components.Settings.Notifications.NotificationsWebhook.validationWebhookUrlRequired": "Você deve fornecer uma URL de webhook", + "components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "Você deve fornecer um conteúdo JSON", + "components.Settings.Notifications.NotificationsWebhook.templatevariablehelp": "Ajuda Com Modelos de Variáveis", + "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON restaurado para conteúdo padrão.", + "components.Settings.Notifications.NotificationsWebhook.resetPayload": "Restaurar Padrão", + "components.Settings.Notifications.NotificationsWebhook.customJson": "Conteúdo JSON Personalizado", + "components.Settings.Notifications.NotificationsWebhook.authheader": "Cabeçalho de Autorização", + "components.Settings.Notifications.agentenabled": "Ativar Agente", + "components.Settings.Notifications.NotificationsWebhook.agentenabled": "Ativar Agente", + "components.Settings.Notifications.webhookUrl": "URL de Webhook", + "components.Settings.Notifications.NotificationsSlack.webhookUrlPlaceholder": "URL de Webhook", + "components.Settings.Notifications.NotificationsWebhook.webhookUrl": "URL de Webhook", + "components.Settings.Notifications.NotificationsSlack.webhookUrl": "URL de Webhook", + "components.Settings.Notifications.NotificationsSlack.testsent": "Notificação de teste enviada!", + "components.Settings.Notifications.testsent": "Notificação de teste enviada!", + "components.Settings.Notifications.NotificationsWebhook.testsent": "Notificação de teste enviada!", + "components.Settings.Notifications.NotificationsSlack.test": "Testar", + "components.Settings.Notifications.NotificationsWebhook.test": "Testar", + "components.Settings.SonarrModal.test": "Testar", + "components.Settings.RadarrModal.test": "Testar", + "components.Settings.Notifications.test": "Testar", + "components.Settings.Notifications.NotificationsSlack.slacksettingssaved": "Configurações de notificação via Slack salvadas!", + "components.Settings.Notifications.NotificationsSlack.slacksettingsfailed": "Falhou o salvar das configurações de notificação do Slack.", + "components.Settings.Notifications.NotificationsSlack.settingupslackDescription": "Para usar notificações via Slack, você precisará criar uma integração Webhook de entrada e usar o URL do webhook fornecido abaixo.", + "components.Settings.Notifications.NotificationsPushover.settinguppushoverDescription": "Para configurar notificações via Pushover, você precisa de registrar um aplicação e obter a chave de acesso. Quando estiver configurando o aplicação, você pode usar um dos ícones no diretório público do GitHub. Você também precisa da chave de acesso que pode ser encontrada na página inicial do utilizador Pushover.", + "components.Settings.Notifications.NotificationsPushover.settinguppushover": "Configurando Notificações via Pushover", + "components.Settings.Notifications.NotificationsSlack.settingupslack": "Configurando Notificações via Slack", + "components.Settings.save": "Salvar Mudanças", + "components.Settings.Notifications.notificationtypes": "Tipos de Notificação", + "components.Settings.Notifications.NotificationsWebhook.notificationtypes": "Tipos de Notificação", + "components.Settings.Notifications.NotificationsSlack.saving": "Salvando…", + "components.Settings.Notifications.saving": "Salvando…", + "components.Settings.RadarrModal.saving": "Salvando…", + "components.Settings.SonarrModal.saving": "Salvando…", + "components.Settings.Notifications.NotificationsWebhook.saving": "Salvando…", + "components.Settings.saving": "Salvando…", + "components.UserEdit.saving": "Salvando…", + "components.Settings.Notifications.NotificationsWebhook.save": "Salvar Mudanças", + "components.Settings.SonarrModal.save": "Salvar Mudanças", + "components.Settings.RadarrModal.save": "Salvar Mudanças", + "components.Settings.Notifications.save": "Salvar Mudanças", + "components.Settings.Notifications.NotificationsSlack.save": "Salvar Mudanças", + "components.Settings.Notifications.NotificationsSlack.notificationtypes": "Tipos de Notificação", + "components.Settings.Notifications.NotificationsSlack.agentenabled": "Ativar Agente", + "components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "Você deve fornecer uma chave de acesso do utilizador", + "components.Settings.Notifications.NotificationsPushover.validationAccessTokenRequired": "Você deve fornecer uma chave de acesso", + "components.Settings.Notifications.NotificationsPushover.userToken": "Chave do Utilizador", + "components.Settings.Notifications.NotificationsPushover.testsent": "Notificação de teste enviada!", + "components.Settings.Notifications.NotificationsPushover.test": "Testar", + "components.Settings.Notifications.NotificationsPushover.saving": "Salvando…", + "components.Settings.Notifications.NotificationsPushover.save": "Salvar Mudanças", + "components.Settings.Notifications.NotificationsPushover.pushoversettingssaved": "Configurações de notificação via Pushover salvadas!", + "components.Settings.Notifications.NotificationsPushover.pushoversettingsfailed": "O salvar das configurações de notificação via Pushover falhou.", + "components.Settings.Notifications.NotificationsPushover.notificationtypes": "Tipos de Notificação", + "components.Settings.Notifications.NotificationsPushover.agentenabled": "Ativar Agente", + "components.Settings.Notifications.NotificationsPushover.accessToken": "Token de Acesso", + "components.Search.searchresults": "Resultados da Pesquisa", + "components.RequestModal.status": "Estado", + "components.RequestModal.selectseason": "Selecione temporada(s)", + "components.RequestModal.seasonnumber": "Temporada {number}", + "components.RequestModal.season": "Temporada", + "components.RequestModal.requesttitle": "Solicitar {title}", + "components.RequestModal.requestseasons": "Solicitar {seasonCount} {seasonCount, plural, one {Temporada} other {Temporadas}}", + "components.RequestModal.requesting": "Solicitando…", + "components.RequestModal.requestfrom": "Existe uma solicitação pendende de {username}", + "components.RequestModal.requestedited": "Solicitação modificada.", + "components.RequestModal.requestcancelled": "Solicitação cancelada.", + "components.RequestModal.cancelrequest": "Isso cancelará sua solicitação. Você tem certeza que quer continuar?", + "components.RequestModal.requestadmin": "Sua solicitação será aprovada imediatamente.", + "components.RequestModal.requestSuccess": "{title} solicitado.", + "components.RequestModal.requestCancel": "Solicitação para {title} cancelada.", + "components.RequestModal.request4ktitle": "Solicitar {title} em 4K", + "components.RequestModal.request4kfrom": "Existe uma solicitação em 4K pendente de {username}.", + "components.RequestModal.request4k": "Solicitar 4K", + "components.RequestModal.request": "Solicitar", + "components.RequestModal.pendingrequest": "Solicitação pendente para {title}", + "components.RequestModal.pending4krequest": "Solicitação em 4K pendente para {title}", + "components.RequestModal.numberofepisodes": "# de Episódeos", + "components.RequestModal.notrequested": "Não Solicitado", + "components.RequestModal.extras": "Extras", + "components.RequestModal.errorediting": "Algo errou modifcando a solicitação.", + "components.RequestModal.close": "Fechar", + "components.RequestModal.cancelling": "Cancelando…", + "components.RequestModal.cancel": "Cancelar Solicitação", + "components.RequestModal.autoapproval": "Aprovação Automática", + "components.RequestModal.AdvancedRequester.rootfolder": "Pasta Raíz", + "components.RequestModal.AdvancedRequester.qualityprofile": "Perfil de Qualidade", + "components.RequestModal.AdvancedRequester.loadingprofiles": "Carregando perfis…", + "components.RequestModal.AdvancedRequester.loadingfolders": "Carregando pastas…", + "components.RequestModal.AdvancedRequester.destinationserver": "Servidor Destino", + "components.RequestModal.AdvancedRequester.default": "(Padrão)", + "components.RequestModal.AdvancedRequester.animenote": "* Esta série é um anime.", + "components.RequestModal.AdvancedRequester.advancedoptions": "Opções Avançadas", + "components.RequestList.status": "Estado", + "components.RequestList.sortModified": "Última Mudança", + "components.RequestList.sortAdded": "Data de Solicitação", + "components.RequestList.showingresults": "Mostrando {from} a {to} de {total} resultados", + "components.RequestList.showallrequests": "Mostrar Todas Solicitações", + "components.RequestList.requests": "Solicitações", + "components.RequestList.requestedAt": "Solicitado No", + "components.RequestList.previous": "Anterior", + "components.RequestList.noresults": "Nenhum resultado.", + "components.RequestList.next": "Próxima", + "components.RequestList.modifiedBy": "Última Mudança Feita Por", + "components.RequestList.mediaInfo": "Títulos", + "components.RequestList.filterPending": "Pendentes", + "components.RequestList.filterApproved": "Aprovadas", + "components.RequestList.filterAll": "Todas", + "components.RequestList.RequestItem.seasons": "Temporadas", + "components.RequestList.RequestItem.requestedby": "Solicitado por {username}", + "components.RequestList.RequestItem.notavailable": "N/A", + "components.RequestList.RequestItem.failedretry": "Algo errou retentando a fazer a solicitação.", + "components.RequestCard.seasons": "Temporadas", + "components.RequestCard.requestedby": "Solicitado por {username}", + "components.RequestCard.all": "Todas", + "components.RequestButton.viewrequest4k": "Ver Solicitação 4K", + "components.RequestButton.viewrequest": "Ver Solicitação", + "components.RequestButton.requestmore4k": "Solicitar Mais 4K", + "components.RequestButton.requestmore": "Solicitar Mais", + "components.RequestButton.request4k": "Solicitar 4K", + "components.RequestButton.request": "Solicitar", + "components.RequestButton.declinerequests": "Rejeitar {requestCount} {requestCount, plural, one {Solicitação} other {Solicitações}}", + "components.RequestButton.declinerequest4k": "Rejeitar Solicitação 4K", + "components.RequestButton.declinerequest": "Rejeitar Solicitação", + "components.RequestButton.decline4krequests": "Rejeitar {requestCount} {requestCount, plural, one {Solicitação} other {Solicitações}} 4K", + "components.RequestButton.approverequests": "Aprovar {requestCount} {requestCount, plural, one {Solicitação} other {Solicitações}}", + "components.RequestButton.approverequest4k": "Aprovar Solicitação 4K", + "components.RequestButton.approverequest": "Aprovar Solicitação", + "components.RequestButton.approve4krequests": "Aprovar {requestCount} {requestCount, plural, one {Solicitação} other {Solicitações}} 4K", + "components.RequestBlock.server": "Servidor", + "components.RequestBlock.seasons": "Temporadas", + "components.RequestBlock.rootfolder": "Pasta Raíz", + "components.RequestBlock.requestoverrides": "Mudanças na Solicitação", + "components.RequestBlock.profilechanged": "Perfil Alterado", + "components.PlexLoginButton.loginwithplex": "Conecte-se com Plex", + "components.PlexLoginButton.loggingin": "Conectando…", + "components.PlexLoginButton.loading": "Carregando…", + "components.PersonDetails.nobiography": "Nenhuma biografia disponível..", + "components.PersonDetails.crewmember": "Membro da Equipa Técnica", + "components.PersonDetails.ascharacter": "como {character}", + "components.PersonDetails.appearsin": "Aparece em", + "components.NotificationTypeSelector.mediafailedDescription": "Envia uma notificação quando mídia falha a ser adicionada aos serviços (Radarr/Sonarr). Para alguns agentes, só enviará a notificação aos administradores ou utilizadores com a permissão “Gerir solicitações”.", + "components.NotificationTypeSelector.mediarequestedDescription": "Envia uma notificação quando nova mídia é solicitada. Para alguns agentes, só enviará a notificação aos administradores ou utilizadores com a permissão “Gerir solicitações”.", + "components.NotificationTypeSelector.mediarequested": "Mídia Solicitada", + "components.NotificationTypeSelector.mediafailed": "Mídia Falhou", + "components.NotificationTypeSelector.mediadeclinedDescription": "Envia uma notificação quando uma solicitação é rejeitada.", + "components.NotificationTypeSelector.mediadeclined": "Mídia Rejeitada", + "components.NotificationTypeSelector.mediaavailableDescription": "Envia uma notificação quando a mídia estiver disponível.", + "components.NotificationTypeSelector.mediaavailable": "Mídia Disponível", + "components.NotificationTypeSelector.mediaapprovedDescription": "Envia uma notificação quando mídia é aprovada.", + "components.NotificationTypeSelector.mediaapproved": "Mídia Aprovada", + "components.MovieDetails.watchtrailer": "Ver Trailer", + "components.MovieDetails.viewfullcrew": "Ver Equipa Técnica Completa", + "components.MovieDetails.view": "Ver", + "components.MovieDetails.userrating": "Avaliação do utilizador", + "components.MovieDetails.unavailable": "Indisponível", + "components.MovieDetails.studio": "Estúdio", + "components.MovieDetails.status": "Estado", + "components.MovieDetails.similarsubtext": "Outros títulos similares a {title}", + "components.MovieDetails.similar": "Títulos Similares", + "components.MovieDetails.runtime": "{minutes} minutos", + "components.MovieDetails.revenue": "Receita", + "components.MovieDetails.releasedate": "Data de Lançamento", + "components.MovieDetails.recommendationssubtext": "Se você gostou de {title}, também pode gostar…", + "components.MovieDetails.recommendations": "Recomendações", + "components.MovieDetails.pending": "Pendente", + "components.MovieDetails.overviewunavailable": "Sinopse indisponível.", + "components.MovieDetails.overview": "Sinopse", + "components.MovieDetails.originallanguage": "Língua original", + "components.MovieDetails.manageModalTitle": "Gerir Filme", + "components.MovieDetails.manageModalRequests": "Solicitações", + "components.MovieDetails.manageModalNoRequests": "Nenhuma Solicitação", + "components.MovieDetails.manageModalClearMediaWarning": "Isso removerá irreversivelmente todos os dados desse filme, incluindo todas as solicitações. Se esse item existir em sua biblioteca Plex, as informações de mídia serão recriadas durante a próxima sincronização.", + "components.MovieDetails.manageModalClearMedia": "Limpar Todos Dados de Mídia", + "components.MovieDetails.decline": "Rejeitar", + "components.MovieDetails.cast": "Elenco", + "components.MovieDetails.cancelrequest": "Cancelar Solicitação", + "components.MovieDetails.budget": "Orçamento", + "components.MovieDetails.available": "Disponível", + "components.MovieDetails.approve": "Aprovar", + "components.MovieDetails.MovieCrew.fullcrew": "Equipa Técnica Completa", + "components.MovieDetails.MovieCast.fullcast": "Elenco Completo", + "components.MediaSlider.ShowMoreCard.seemore": "Ver Mais", + "components.Login.validationpasswordrequired": "Palavra-passe necessária", + "components.Login.validationemailrequired": "E-mail inválido", + "components.Login.signinwithoverseerr": "Conecte-se com sua conta Overseerr", + "components.Login.signinplex": "Conecte para continuar", + "components.Login.loginerror": "Algo errou tentando a conectar-se.", + "components.Login.login": "Conecte-se", + "components.Login.password": "Palavra-passe", + "components.Login.loggingin": "Conectando…", + "components.Login.goback": "Voltar", + "components.Login.email": "Endereço de E-mail", + "components.Layout.alphawarning": "Este é uma versão Alpha. Funcionalidades podeem quebrar ou se tornar instável. Por favor, reporte os problemas no GitHub!", + "components.Layout.UserDropdown.signout": "Sair", + "components.Layout.Sidebar.users": "Utilizadores", + "components.Layout.Sidebar.settings": "Configurações", + "components.Layout.Sidebar.requests": "Solicitações", + "components.Layout.Sidebar.dashboard": "Descobrir", + "components.Layout.SearchInput.searchPlaceholder": "Pesquisar Filmes & Séries", + "components.Layout.LanguagePicker.changelanguage": "Mudar Idioma", + "components.Discover.upcomingmovies": "Próximos Filmes", + "components.Discover.upcoming": "Próximos Filmes", + "components.Discover.trending": "Tendendo", + "components.Discover.recentrequests": "Solicitações Recentes", + "components.Discover.recentlyAdded": "Adicionado Recentemente", + "components.Discover.populartv": "Séries Populares", + "components.Discover.popularmovies": "Filmes Populares", + "components.Discover.nopending": "Nenhuma Solicitação Pendente", + "components.Discover.discovertv": "Séries Populares", + "components.Discover.discovermovies": "Filmes Populares", + "components.CollectionDetails.requestswillbecreated": "Os títulos seguintes terão solicitações criadas para eles:", + "components.CollectionDetails.requesting": "Solicitando…", + "components.CollectionDetails.requestcollection": "Solicitar Coleção", + "components.CollectionDetails.requestSuccess": "{title} solicitado com sucesso!", + "components.CollectionDetails.request": "Solicitar", + "components.CollectionDetails.overviewunavailable": "Sinopse indisponível.", + "components.CollectionDetails.overview": "Sinopse", + "components.CollectionDetails.numberofmovies": "Número de filmes: {count}", + "pages.somethingWentWrong": "statusCode} - Algo errou", + "pages.serviceUnavailable": "{statusCode} - Serviço Indisponível", + "pages.returnHome": "Voltar Para Página Inicial", + "pages.pageNotFound": "404 - Página Não Encontrada", + "pages.oops": "Oops", + "pages.internalServerError": "{statusCode} - Erro Interno no Servidor", + "i18n.tvshows": "Séries", + "i18n.retry": "Retentar", + "i18n.requested": "Solicitado", + "i18n.processing": "Processando…", + "i18n.partiallyavailable": "Parcialmente Disponível", + "i18n.movies": "Filmes", + "i18n.failed": "Falhou", + "i18n.experimental": "Experimental", + "i18n.deleting": "Apagando…", + "i18n.declined": "Rejeitado", + "i18n.close": "Fechar", + "i18n.cancel": "Cancelar", + "i18n.approved": "Aprovada", + "i18n.approve": "Aprovar", + "components.UserList.validationpasswordminchars": "Palavra-passe muito curta; necessário ter no mínimo 8 caracteres", + "components.UserList.validationemailrequired": "Deve inserir um endereço de e-mail válido", + "components.UserList.usertype": "Tipo de Utilizador", + "components.UserList.username": "Utilizador", + "components.UserList.userlist": "Lista de Utilizadores", + "components.UserList.userdeleteerror": "Algo errou apagando o utilizador.", + "components.UserList.userdeleted": "Utilizador apago.", + "components.UserList.usercreatedsuccess": "Utilizador criado com sucesso!", + "components.UserList.usercreatedfailed": "Algo errou criando o utilizador.", + "components.UserList.user": "Utilizador", + "components.UserList.totalrequests": "Total de Solicitações", + "components.UserList.role": "Privilégio", + "components.UserList.plexuser": "Utilizador Plex", + "components.UserList.passwordinfodescription": "Para usar a geração automática de palavras-passe, as notificações por e-mail precisam ser configuradas e ativadas.", + "components.UserList.passwordinfo": "Informações de Palavra-passe", + "components.UserList.localuser": "Utilizador Local", + "components.UserList.lastupdated": "Última Atualização", + "components.UserList.importfromplexerror": "Algo errou importando utilizadores do Plex.", + "components.UserList.importfromplex": "Importar Utilizadores do Plex", + "components.UserList.importedfromplex": "{userCount, plural, =0 {Nenhum novo utilizador} one {# novo utilizador} other {# novos utilizadores}} importado(s) do Plex", + "components.UserList.email": "Endereço de E-mail", + "components.UserList.deleteuser": "Apagar Utilizador", + "components.UserList.deleteconfirm": "Tem certeza que deseja apagar esse utilizador? Todas informações de solicitação desse utilizador serão apagas.", + "components.UserList.creating": "Criando", + "components.UserList.createuser": "Criar Utilizador", + "components.UserList.createlocaluser": "Criar Utilizador Local", + "components.Slider.noresults": "Nenhum resultado.", + "components.Setup.welcome": "Bem-Vindo ao Overseerr", + "components.Setup.tip": "Dica", + "components.Setup.syncingbackground": "A sincronização será executada em segundo plano. Você pode continuar a configuração entretanto.", + "components.Setup.signinMessage": "Comece conectando-se com sua conta Plex", + "components.Setup.loginwithplex": "Conecte-se com Plex", + "components.Setup.finishing": "Finalizando…", + "components.Setup.finish": "Finalizar Configurações", + "components.Setup.continue": "Continuar", + "components.Setup.configureservices": "Configurar Serviços", + "components.Setup.configureplex": "Configurar Plex", + "components.Settings.toastSettingsSuccess": "Configurações salvadas.", + "components.Settings.toastSettingsFailure": "Algo errou salvando as configurações.", + "components.Settings.toastApiKeySuccess": "Nova chave API gerada!", + "components.Settings.toastApiKeyFailure": "Algo errou gerando uma nova chave API.", + "components.Settings.syncing": "Sincronizando…", + "components.Settings.sync": "Sincronizar Bibliotecas do Plex", + "components.Settings.startscan": "Iniciar Scaneamento", + "components.Settings.sonarrsettings": "Configurações do Sonarr", + "components.Settings.sonarrSettingsDescription": "Configure abaixo sua conexão com Sonarr. Você pode criar várias conexões, mas apenas duas por vez como padrão (uma para padrão HD, e outra para 4K). Administradores podem alterar qual conexão será usada para novas solicitações.", + "components.Settings.servernamePlaceholder": "Nome do Servidor Plex", + "components.Settings.servername": "Nome do Servidor", + "components.Settings.runnow": "Executar Agora", + "components.Settings.radarrsettings": "Configurações do Radarr", + "components.Settings.radarrSettingsDescription": "Configure abaixo sua conexão com Radarr. Você pode criar várias conexões, mas apenas duas por vez como padrão (uma para padrão HD, e outra para 4K). Administradores podem alterar qual servidor será usado para novas solicitações.", + "components.Settings.port": "Porta", + "components.Settings.plexsettingsDescription": "Define as configurações para o seu servidor Plex. Overseerr escaneará sua biblioteca em intervalos e verá qual conteúdo está disponível.", + "components.Settings.plexlibraries": "Bibliotecas do Plex", + "components.Settings.plexsettings": "Configurações do Plex", + "components.Settings.plexlibrariesDescription": "Bibliotecas que Overseerr escaneará por títulos. Configure e salve as informações de conexão com Plex e clique no botão abaixo se nenhuma biblioteca é listada.", + "components.Settings.notrunning": "Parado", + "components.Settings.notificationsettingsDescription": "Define as configurações de notificação global. As configurações abaixo afetarão todos os agentes de notificação.", + "components.Settings.notificationsettings": "Configurações de Notificação", + "components.Settings.nodefaultdescription": "Ao menos um servidor deve ser selecionado como padrão antes que qualquer solicitação chegue aos seus serviços.", + "components.Settings.nodefault": "Nenhum servidor padrão selecionado!", + "components.Settings.nextexecution": "Próxima Execução", + "components.Settings.menuServices": "Serviços", + "components.Settings.menuPlexSettings": "Plex", + "components.Settings.menuNotifications": "Notificações", + "components.Settings.menuLogs": "Logs", + "components.Settings.menuJobs": "Tarefas", + "components.Settings.menuAbout": "Sobre", + "components.Settings.manualscanDescription": "Normalmente, isto só será executado uma vez a cada 24 horas. Overseerr verificará em detalhes items adicionados recentemente ao seu servidor Plex. Se esta é a primeira vez que você configura um servidor Plex, é recomendado um scaneamento completo de sua biblioteca!", + "components.Settings.manualscan": "Scaneamento Manual da Biblioteca", + "components.Settings.cancelscan": "Cancelar Scaneamento", + "components.Settings.librariesRemaining": "Bibliotecas Restantes: {count}", + "components.Settings.jobname": "Nome da Tarefa", + "components.Settings.hostname": "Nome de Host/IP", + "components.Settings.hideAvailable": "Esconder Mídia Disponível", + "components.Settings.generalsettingsDescription": "Defina configurações globais e padrões para o Overseerr.", + "components.Settings.menuGeneralSettings": "Configurações Gerais", + "components.Settings.generalsettings": "Configurações Gerais", + "i18n.edit": "Modificar", + "components.UserList.edit": "Modificar", + "components.Settings.edit": "Modificar", + "components.Settings.deleteserverconfirm": "Tem certeza que deseja apagar esse servidor?", + "components.Settings.delete": "Apagar", + "components.UserList.delete": "Apagar", + "i18n.delete": "Apagar", + "components.Settings.defaultPermissions": "Permissões Padrões de Utilizadores", + "components.Settings.default4k": "Padrão 4K", + "components.Settings.default": "Padrão", + "components.Settings.currentlibrary": "Biblioteca Atual: {name}", + "components.Settings.copied": "Chave API copiada.", + "components.Settings.applicationurl": "URL da Aplicação", + "components.Settings.apikey": "Chave API", + "components.Settings.addsonarr": "Adicionar Servidor Sonarr", + "components.Settings.address": "Endereço", + "components.Settings.addradarr": "Adicionar Servidor Radarr", + "components.Settings.activeProfile": "Perfil Ativo", + "components.Settings.SonarrModal.validationRootFolderRequired": "Você deve selecionar uma pasta raíz", + "components.Settings.SonarrModal.validationProfileRequired": "Você deve selecionar um perfil de qualidade", + "components.Settings.SonarrModal.validationNameRequired": "Você deve fornecer o nome do servidor", + "components.Settings.RadarrModal.validationHostnameRequired": "Você deve fornecer o nome do host/IP", + "components.Settings.SonarrModal.validationHostnameRequired": "Você deve fornecer o nome do host/IP", + "components.Settings.SonarrModal.validationPortRequired": "Você deve fornecer uma porta", + "components.Settings.validationPortRequired": "Você deve fornecer uma porta", + "components.Settings.validationHostnameRequired": "Você deve fornecer o nome do host/IP", + "components.Settings.SonarrModal.validationApiKeyRequired": "Você deve fornecer uma chave API", + "components.Settings.SonarrModal.toastRadarrTestSuccess": "Conexão estabelecida com servidor Sonarr!", + "components.Settings.SonarrModal.toastRadarrTestFailure": "Falhou a conectar ao Servidor Sonarr", + "components.Settings.SonarrModal.testFirstRootFolders": "Teste conexão para carregar as pastas raízes", + "components.Settings.SonarrModal.testFirstQualityProfiles": "Teste conexão para carregar perfis de qualidade", + "components.Settings.ssl": "SSL", + "components.Settings.SonarrModal.ssl": "SSL", + "components.Settings.SonarrModal.servernamePlaceholder": "Meu Servidor Sonarr", + "components.Settings.SonarrModal.seasonfolders": "Temporadas Em Pastas", + "components.Settings.SonarrModal.qualityprofile": "Perfil de Qualidade", + "components.Settings.SonarrModal.port": "Porta", + "components.Settings.SonarrModal.loadingrootfolders": "Carregando pastas raízes…", + "components.Settings.SonarrModal.loadingprofiles": "Carregando Perfis de Qualidade…", + "components.Settings.SonarrModal.hostname": "Nome do Host / IP", + "components.Settings.SonarrModal.editsonarr": "Editar Servidor Sonarr", + "components.Settings.SonarrModal.defaultserver": "Servidor Padrão", + "components.Settings.SonarrModal.createsonarr": "Criar Um Novo Servidor Sonarr", + "components.Settings.SonarrModal.baseUrlPlaceholder": "Exemplo: /sonarr", + "components.Settings.SonarrModal.apiKeyPlaceholder": "Sua chave API do Sonarr", + "components.Settings.SonarrModal.apiKey": "Chave API", + "components.Settings.RadarrModal.validationApiKeyRequired": "Você deve fornecer uma chave API", + "components.Settings.SonarrModal.baseUrl": "URL Base", + "components.Settings.SonarrModal.animerootfolder": "Pasta Raíz de Animes", + "components.Settings.SonarrModal.animequalityprofile": "Perfil de Qualidade Para Animes", + "components.Settings.SonarrModal.add": "Adicionar Servidor", + "components.Settings.SettingsAbout.version": "Versão", + "components.Settings.SettingsAbout.totalrequests": "Total de Solicitações", + "components.Settings.SettingsAbout.totalmedia": "Total de Mídia", + "components.Settings.SettingsAbout.timezone": "Fuso Horário", + "components.Settings.SettingsAbout.supportoverseerr": "Apoie o Overseerr", + "components.Settings.SettingsAbout.overseerrinformation": "Sobre Overseerr", + "components.Settings.SettingsAbout.helppaycoffee": "Ajude a Pagar o Café", + "components.Settings.SettingsAbout.githubdiscussions": "Discussões no GitHub", + "components.Settings.SettingsAbout.gettingsupport": "Obter Suporte", + "components.Settings.SettingsAbout.documentation": "Documentação", + "components.Settings.SettingsAbout.clickheretojoindiscord": "Clique aqui para participar no nosso servidor Discord!", + "components.Settings.SettingsAbout.Releases.viewongithub": "Ver no GitHub", + "components.Settings.SettingsAbout.Releases.viewchangelog": "Ver Mudanças", + "components.Settings.SettingsAbout.Releases.versionChangelog": "Mudanças Nesta Versão", + "components.Settings.SettingsAbout.Releases.runningDevelopMessage": "As mudanças na sua versão não serão exibidas abaixo. Por favor sonsulte o repositório do GitHub para as últimas atualizações.", + "components.Settings.SettingsAbout.Releases.runningDevelop": "Você está usando uma versão de desenvolvimento do Overseerr!", + "components.Settings.SettingsAbout.Releases.releases": "Versões", + "components.Settings.SettingsAbout.Releases.releasedataMissing": "Informações de versão indisponíveis. O GitHub está abaixo?", + "components.Settings.SettingsAbout.Releases.latestversion": "Última Versão", + "components.Settings.SettingsAbout.Releases.currentversion": "Versão Atual", + "components.Settings.RadarrModal.validationRootFolderRequired": "Você deve selecionar uma pasta raíz", + "components.Settings.RadarrModal.validationProfileRequired": "Você deve selecionar um perfil de qualidade", + "components.Settings.RadarrModal.validationPortRequired": "Você deve fornecer uma porta", + "components.Settings.RadarrModal.validationNameRequired": "Você deve fornecer o nome do servidor", + "components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "Você deve selecionar a disponibilidade mínima", + "components.Settings.RadarrModal.toastRadarrTestSuccess": "Conexão estabelecida com Radarr!", + "components.Settings.RadarrModal.toastRadarrTestFailure": "Falhou a conectar ao Radarr.", + "components.UserList.password": "Palavra-passe", + "components.UserList.created": "Criado", + "components.UserList.create": "Criar", + "components.UserList.autogeneratepassword": "Gerar palavra-passe automaticamente", + "components.UserEdit.voteDescription": "Concede permissão para votar em solicitações (sistema de votos ainda não implementado)", + "components.UserEdit.vote": "Votar", + "components.UserEdit.usersaved": "Utilizador salvado", + "components.UserEdit.usersDescription": "Concede permissão para gerir utilizadores do Overseerr. Utilizadores com essa permissão não conseguem modificar utilizadores Administradores ou conceder esse privilégio .", + "components.UserEdit.users": "Gerir Utilizadores", + "components.UserEdit.username": "Nome de Exibição", + "components.UserEdit.userfail": "Algo errou salvando o utilizador.", + "components.UserEdit.settingsDescription": "Concede permissão para modificar todas configurações do Overseerr. Um utilizador precisa dessa permissão para concedê-la para outros utilizadores.", + "components.UserEdit.settings": "Gerir Configurações", + "components.UserEdit.save": "Salvar", + "components.UserEdit.requestDescription": "Concede permissão para solicitar filmes e séries.", + "components.UserEdit.request4kTvDescription": "Concede permissão para solicitar séries em 4K.", + "components.UserEdit.request4kTv": "Solicitar Séries em 4K", + "components.UserEdit.request4kMoviesDescription": "Concede permissão para solicitar filmes em 4K.", + "components.UserEdit.request4kMovies": "Solicitar Filmes em 4K", + "components.UserEdit.request4kDescription": "Concede permissão para solicitar filmes e séries em 4K.", + "components.UserEdit.request4k": "Solicitar 4K", + "i18n.request": "Solicitar", + "components.UserEdit.request": "Solicitação", + "components.UserEdit.permissions": "Permissões", + "components.UserEdit.managerequestsDescription": "Concede permissão para gerir solicitações no Overseerr. Isso inclui aprová-las e negá-las.", + "components.UserEdit.managerequests": "Gerir Solicitações", + "components.UserEdit.email": "E-mail", + "components.UserEdit.edituser": "Modificar Utilizador", + "components.UserEdit.avatar": "Avatar", + "components.UserEdit.autoapproveSeriesDescription": "Concede aprovação automática de séries solicitadas por este utilizador.", + "components.UserEdit.autoapproveSeries": "Aprovar Séries Automaticamente", + "components.UserEdit.autoapproveMoviesDescription": "Concede aprovação automática de filmes solicitados por este utilizador.", + "components.UserEdit.autoapproveMovies": "Aprovar Filmes Automaticamente", + "components.UserEdit.autoapproveDescription": "Concede aprovação automática para todas as solicitações feitas por este utilizador.", + "components.UserEdit.autoapprove": "Aprovação Automática", + "components.UserEdit.advancedrequestDescription": "Concede permissão para usar opções de solicitações avançadas. (Ex. Alterar servidores / perfis / caminhos)", + "components.UserEdit.advancedrequest": "Solicitações Avançadas", + "components.UserEdit.adminDescription": "Acesso total de administrador. Ignora todas as verificações de permissão.", + "components.UserList.admin": "Administrador", + "components.UserEdit.admin": "Administrador", + "components.TvDetails.watchtrailer": "Ver Trailer", + "components.TvDetails.viewfullcrew": "Ver Equipa Técnica Completa", + "components.TvDetails.userrating": "Avaliação do utilizador", + "i18n.unavailable": "Indisponível", + "components.TvDetails.unavailable": "Indisponível", + "components.TvDetails.status": "Estado", + "components.TvDetails.similarsubtext": "Outras séries similares a {title}", + "components.TvDetails.similar": "Séries Similares", + "components.TvDetails.showtype": "Categoria", + "components.TvDetails.recommendationssubtext": "Se você gostou de {title}, também pode gostar…", + "components.TvDetails.recommendations": "Recomendações", + "i18n.pending": "Pendente", + "components.TvDetails.pending": "Pendente", + "components.TvDetails.overviewunavailable": "Sinopse indisponível.", + "components.TvDetails.overview": "Sinopse", + "components.TvDetails.originallanguage": "Língua original", + "components.TvDetails.network": "Estúdio", + "components.TvDetails.manageModalTitle": "Gerir Série", + "components.TvDetails.manageModalRequests": "Solicitações", + "components.TvDetails.manageModalNoRequests": "Nenhuma Solicitação", + "components.TvDetails.manageModalClearMediaWarning": "Isso removerá irreversivelmente todos os dados dessa séries, incluindo todas as solicitações. Se esse item existir em sua biblioteca Plex, as informações de mídia serão recriadas durante a próxima sincronização.", + "components.TvDetails.manageModalClearMedia": "Limpar Todos Dados de Mídia", + "components.TvDetails.firstAirDate": "Primeira Exibição", + "i18n.decline": "Rejeitar", + "components.TvDetails.decline": "Rejeitar", + "components.TvDetails.cast": "Elenco", + "components.TvDetails.cancelrequest": "Cancelar Solicitação", + "i18n.available": "Disponível", + "components.TvDetails.available": "Disponível", + "components.TvDetails.approve": "Aprovar", + "components.TvDetails.anime": "Animes", + "components.TvDetails.TvCrew.fullseriescrew": "Equipa Técnica Completa da Série", + "components.TvDetails.TvCast.fullseriescast": "Elenco Completo da Série", + "components.TitleCard.tvshow": "Séries", + "components.TitleCard.movie": "Filme", + "components.StatusChacker.reloadOverseerr": "Reiniciar Overseerr", + "components.StatusChacker.newversionavailable": "Nova Versão Disponível", + "components.StatusChacker.newversionDescription": "Uma atualização está disponível. Clique no botão abaixo para reiniciar a aplicação.", + "components.StatusBadge.status4k": "4K {status}", + "components.Login.signinwithplex": "Conecte-se com sua conta Plex", + "components.RequestModal.requesterror": "Algo errou tentando submeter a solicitação.", + "components.RequestModal.notvdbiddescription": "Adicione o ID TVDB ao TMDb e volte a tentar mais tarde, ou selecione a associação correta abaixo:", + "components.RequestModal.notvdbid": "Nenhum ID TVDB foi encontrado para este item no TMDb.", + "components.RequestModal.backbutton": "Voltar", + "components.RequestModal.SearchByNameModal.notvdbiddescription": "Não foi possível associar sua solicitação automaticamente. Por favor selecione a associação correta na lista abaixo:", + "components.RequestModal.SearchByNameModal.notvdbid": "Associação Manual Necessária", + "components.RequestModal.SearchByNameModal.nosummary": "Sinopse não encontrada para este título.", + "components.RequestModal.next": "Próxima", + "components.RequestModal.SearchByNameModal.next": "Próxima", + "components.Login.signinheader": "Conecte-se para continuar", + "components.Login.signingin": "Conectando…", + "components.Login.signin": "Conecte-se", + "components.Settings.notificationsettingssaved": "Configurações de notificação salvadas!", + "components.Settings.enablenotifications": "Ativar notificações", + "components.Settings.autoapprovedrequests": "Enviar Notificações para Solicitações Aprovadas Automaticamente", + "components.Settings.notificationsettingsfailed": "Falhou a salvar as configurações de notificação.", + "components.Settings.notificationAgentsSettings": "Agentes de Notificação", + "components.Settings.notificationAgentSettingsDescription": "Escolhe os tipos de notificações a enviar e quais agentes de notificação usar.", + "components.PlexLoginButton.signinwithplex": "Conecte-se", + "components.PlexLoginButton.signingin": "Conectando…", + "components.UserList.userssaved": "Utilizadores salvos", + "components.UserList.bulkedit": "Edição em Massa", + "components.Settings.toastPlexRefreshSuccess": "Lista de servidores obtida de Plex.", + "components.Settings.toastPlexRefreshFailure": "Incapaz de obter a lista de servidores do Plex!", + "components.Settings.toastPlexRefresh": "Obtendo lista de servidores do Plex…", + "components.Settings.toastPlexConnectingSuccess": "Conectado ao servidor Plex.", + "components.Settings.toastPlexConnectingFailure": "Incapaz de conectar ao Plex!", + "components.Settings.toastPlexConnecting": "Tentando conectar ao Plex…", + "components.Settings.timeout": "Timeout", + "components.Settings.settingUpPlexDescription": "Para configurar o Plex, você pode inserir seus detalhes manualmente ou selecionar um dos servidores disponíveis obtidos de plex.tv. Clique o botão à direita da lista suspensa para verificar a conectividade e recuperar os servidores disponíveis.", + "components.Settings.settingUpPlex": "Configurando Plex", + "components.Settings.serverpresetRefreshing": "Obtendo servidores…", + "components.Settings.serverpresetPlaceholder": "Servidor Plex", + "components.Settings.serverpresetManualMessage": "Configurar manualmente", + "components.Settings.serverpresetLoad": "Clique no botão para carregar os servidores disponíveis", + "components.Settings.serverpreset": "Servidor", + "components.Settings.serverRemote": "remoto", + "components.Settings.serverLocal": "local", + "components.Settings.serverConnected": "conectado", + "components.Settings.ms": "ms", + "components.Settings.csrfProtectionTip": "Define o acesso externo da API para somente leitura (Overseerr deve ser recarregado para que as alterações tenham efeito)", + "components.Settings.csrfProtection": "Ativar Proteção CSRF", + "components.PermissionEdit.voteDescription": "Concede permissão para votar em solicitações (votação ainda não implementada)", + "components.PermissionEdit.vote": "Votar", + "components.PermissionEdit.usersDescription": "Concede permissão para gerir utilizadores Overseerr. Os utilizadores com essa permissão não podem modificar utilizadores com privilégio de administrador ou concedê-lo.", + "components.PermissionEdit.users": "Gerir Utilizadores", + "components.PermissionEdit.settingsDescription": "Concede permissão para modificar todas as configurações de Overseerr. Um utilizador deve ter essa permissão para concedê-la a outras pessoas.", + "components.PermissionEdit.settings": "Gerir Configurações", + "components.PermissionEdit.requestDescription": "Concede permissão para solicitar filmes e séries.", + "components.PermissionEdit.request4kTvDescription": "Concede permissão para solicitar séries em 4K.", + "components.PermissionEdit.request4kTv": "Solicitar Séries 4K", + "components.PermissionEdit.request4kMoviesDescription": "Concede permissão para solicitar filmes em 4K.", + "components.PermissionEdit.request4kMovies": "Solicitar Filmes 4K", + "components.PermissionEdit.request4kDescription": "Concede permissão para solicitar filmes e séries em 4K.", + "components.PermissionEdit.request4k": "Solicitar 4K", + "components.PermissionEdit.request": "Solicitar", + "components.PermissionEdit.managerequestsDescription": "Concede permissão para gerir solicitações Overseerr. Isso inclui aprovar e rejaitar solicitações.", + "components.PermissionEdit.managerequests": "Gerir Solicitações", + "components.PermissionEdit.autoapproveSeriesDescription": "Concede aprovação automática para solicitações de séries feitas por esse utilizador.", + "components.PermissionEdit.autoapproveSeries": "Aprovar Séries Automaticamente", + "components.PermissionEdit.autoapproveMoviesDescription": "Concede aprovação automática para solicitações de filmes feitas por esse utilizador.", + "components.PermissionEdit.autoapproveMovies": "Aprovar Filmes Automaticamente", + "components.PermissionEdit.autoapprove": "Aprovação Automática", + "components.PermissionEdit.autoapproveDescription": "Concede aprovação automática para todas as solicitações feitas por esse utilizador.", + "components.PermissionEdit.advancedrequestDescription": "Concede permissão para fazer solicitações avançadas; ex. escolher servidores, perfis ou caminhos.", + "components.PermissionEdit.advancedrequest": "Solicitações Avançadas", + "components.PermissionEdit.adminDescription": "Acesso total de administrador. Ignora todas as verificações de permissão.", + "components.PermissionEdit.admin": "Administrador", + "components.UserEdit.plexUsername": "Utilizador Plex", + "components.TvDetails.opensonarr4k": "Abrir série no Sonarr 4K", + "components.TvDetails.opensonarr": "Abrir Série no Sonarr", + "components.Settings.servernameTip": "Obtido automaticamente do Plex após salvar", + "components.Settings.jobtype": "Tipo", + "components.Settings.jobstarted": "{jobname} iniciado(a).", + "components.Settings.jobcancelled": "{jobname} cancelado(a).", + "components.Settings.canceljob": "Cancelar Tarefa", + "components.Settings.SonarrModal.toastSonarrTestSuccess": "Conexão Sonarr estabelecida!", + "components.Settings.SonarrModal.toastSonarrTestFailure": "Falha ao conectar ao Sonarr.", + "components.Settings.SonarrModal.externalUrlPlaceholder": "URL Externa do Sonarr", + "components.Settings.SonarrModal.syncEnabled": "Ativar Sincronização", + "components.Settings.RadarrModal.syncEnabled": "Ativar Sincronização", + "components.Settings.SonarrModal.preventSearch": "Desativar Busca Automática", + "components.Settings.RadarrModal.preventSearch": "Desativar Busca Automática", + "components.Settings.SonarrModal.externalUrl": "URL Externa", + "components.Settings.RadarrModal.externalUrlPlaceholder": "URL externa do Radarr", + "components.Settings.RadarrModal.externalUrl": "URL Externa", + "components.TvDetails.playonplex": "Ver no Plex", + "components.TvDetails.play4konplex": "Ver em 4K no Plex", + "components.MovieDetails.play4konplex": "Ver em 4K no Plex", + "components.MovieDetails.playonplex": "Ver no Plex", + "components.MovieDetails.openradarr4k": "Abrir filme no Radarr 4K", + "components.MovieDetails.openradarr": "Abrir filme no Radarr", + "components.TvDetails.downloadstatus": "Estado do download", + "components.MovieDetails.downloadstatus": "Estado do Download", + "components.MovieDetails.areyousure": "Tem certeza?", + "components.TvDetails.areyousure": "Tem certeza?", + "components.Common.ListView.noresults": "Nenhum resultado." +} diff --git a/src/i18n/locale/ru.json b/src/i18n/locale/ru.json index 5fa738af..c0f60910 100644 --- a/src/i18n/locale/ru.json +++ b/src/i18n/locale/ru.json @@ -11,12 +11,12 @@ "components.Discover.upcomingmovies": "Предстоящие фильмы", "components.Layout.LanguagePicker.changelanguage": "Изменить язык", "components.Layout.SearchInput.searchPlaceholder": "Поиск фильмов и сериалов", - "components.Layout.Sidebar.dashboard": "", + "components.Layout.Sidebar.dashboard": "Обнаружение", "components.Layout.Sidebar.requests": "Запросы", "components.Layout.Sidebar.settings": "Настройки", "components.Layout.Sidebar.users": "Пользователи", "components.Layout.UserDropdown.signout": "Выход", - "components.Layout.alphawarning": "", + "components.Layout.alphawarning": "Это АЛЬФА-версия этой программы. Почти все может быть сломано и/или нестабильно. Пожалуйста, сообщайте о проблемах на GitHub Overseerr!", "components.Login.signinplex": "Войдите, чтобы продолжить", "components.MovieDetails.approve": "Одобрить", "components.MovieDetails.available": "Доступно", @@ -131,8 +131,8 @@ "components.Settings.RadarrModal.validationApiKeyRequired": "", "components.Settings.RadarrModal.validationHostnameRequired": "", "components.Settings.RadarrModal.validationPortRequired": "", - "components.Settings.RadarrModal.validationProfileRequired": "Вы должны выбрать профиль", - "components.Settings.RadarrModal.validationRootFolderRequired": "Вы должны выбрать корневую папку", + "components.Settings.RadarrModal.validationProfileRequired": "Вы должны выбрать профиль качества.", + "components.Settings.RadarrModal.validationRootFolderRequired": "Вы должны выбрать корневую папку.", "components.Settings.SonarrModal.add": "Добавить сервер", "components.Settings.SonarrModal.apiKey": "Ключ API", "components.Settings.SonarrModal.apiKeyPlaceholder": "", @@ -307,5 +307,8 @@ "components.CollectionDetails.overviewunavailable": "Обзор недоступен", "components.CollectionDetails.overview": "Обзор", "components.CollectionDetails.numberofmovies": "Количество фильмов: {count}", - "components.CollectionDetails.movies": "Фильмы" + "components.CollectionDetails.movies": "Фильмы", + "components.CollectionDetails.requesting": "Запрашивается…", + "components.CollectionDetails.requestcollection": "Запросить Коллекцию", + "components.CollectionDetails.requestSuccess": "{title} успешно запрошено!" } diff --git a/src/i18n/locale/sr.json b/src/i18n/locale/sr.json index 9555e726..60778495 100644 --- a/src/i18n/locale/sr.json +++ b/src/i18n/locale/sr.json @@ -332,7 +332,7 @@ "components.MovieDetails.approve": "Odobri", "components.MovieDetails.MovieCast.fullcast": "Kompletna Glumačka Postava", "components.Login.signinplex": "Prijavite se da bi ste nastavili", - "components.Layout.alphawarning": "Ovo je softver u ALPHA stanju. Skoro sve je nestabilno. Molimo Vas prijavite sve probleme na Overseer GitHub stranici!", + "components.Layout.alphawarning": "Ovo je softver u ALPHA stanju. Skoro sve je nestabilno. Molimo Vas prijavite sve probleme na Overseerr GitHub stranici!", "components.Layout.UserDropdown.signout": "Odjava", "components.Layout.Sidebar.users": "Korisnici", "components.Layout.Sidebar.settings": "Podešavanja", diff --git a/src/i18n/locale/sv.json b/src/i18n/locale/sv.json index 5943225c..217d8133 100644 --- a/src/i18n/locale/sv.json +++ b/src/i18n/locale/sv.json @@ -26,7 +26,7 @@ "components.Settings.port": "Port", "components.Settings.plexsettingsDescription": "Konfigurera inställningarna för din Plex-server. Overseerr använder din Plex-server för att scanna dina mediabibliotek på givna intervaller för att se vilka media som är tillgängliga.", "components.Settings.plexsettings": "Plexinställningar", - "components.Settings.plexlibrariesDescription": "Mediabiblioteken Overseer scannar för titlar. Konfigurera och spara dina Plex anslutningsinställningar och klicka på knappen nedan utifall inga är listade.", + "components.Settings.plexlibrariesDescription": "Mediabiblioteken Overseerr scannar för titlar. Konfigurera och spara dina Plex anslutningsinställningar och klicka på knappen nedan utifall inga är listade.", "components.Settings.plexlibraries": "Plex Bibliotek", "components.Settings.notrunning": "Körs ej", "components.Settings.notificationsettingsDescription": "Här kan du välja vilka typer av notifikationer som skall skickas och med vilken typ av tjänst.", @@ -377,7 +377,7 @@ "components.Settings.Notifications.NotificationsSlack.slacksettingssaved": "Notiferingsinställningar för Slack sparade!", "components.Settings.Notifications.NotificationsSlack.slacksettingsfailed": "Notifieringsinställningar för Slack misslyckades att sparas.", "components.Settings.Notifications.NotificationsSlack.settingupslack": "Ställa in Slack Notifikationer", - "components.Settings.Notifications.NotificationsSlack.saving": "Sparar...", + "components.Settings.Notifications.NotificationsSlack.saving": "Sparar…", "components.Settings.Notifications.NotificationsSlack.save": "Spara Ändringar", "components.Settings.Notifications.NotificationsSlack.agentenabled": "Aktiverad", "components.MovieDetails.watchtrailer": "Kolla Trailer", @@ -430,7 +430,7 @@ "components.Settings.Notifications.NotificationsWebhook.testsent": "Testmeddelande skickat!", "components.Settings.Notifications.NotificationsWebhook.test": "Test", "components.Settings.Notifications.NotificationsWebhook.templatevariablehelp": "Lathund för variabler", - "components.Settings.Notifications.NotificationsWebhook.saving": "Sparar...", + "components.Settings.Notifications.NotificationsWebhook.saving": "Sparar…", "components.Settings.Notifications.NotificationsWebhook.save": "Spara Ändringar", "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON återställd till standard.", "components.Settings.Notifications.NotificationsWebhook.resetPayload": "Återställ till standard JSON", @@ -448,7 +448,7 @@ "components.Settings.Notifications.NotificationsPushover.testsent": "Testmeddelande skickat!", "components.Settings.Notifications.NotificationsPushover.test": "Test", "components.Settings.Notifications.NotificationsPushover.settinguppushover": "Konfiguration av Pushover-notifieringar", - "components.Settings.Notifications.NotificationsPushover.saving": "Sparar...", + "components.Settings.Notifications.NotificationsPushover.saving": "Sparar…", "components.Settings.Notifications.NotificationsPushover.save": "Spara Ändringar", "components.Settings.Notifications.NotificationsPushover.pushoversettingssaved": "Inställningar för pushover-meddelanden sparade!", "components.Settings.Notifications.NotificationsPushover.pushoversettingsfailed": "Inställningar för pushover-meddelanden kunde inte sparas.", diff --git a/src/i18n/locale/zh_Hant.json b/src/i18n/locale/zh_Hant.json index e1f667bc..099948cf 100644 --- a/src/i18n/locale/zh_Hant.json +++ b/src/i18n/locale/zh_Hant.json @@ -1,12 +1,12 @@ { "components.Settings.Notifications.webhookUrl": "網絡鉤手網址", - "components.Settings.Notifications.validationWebhookUrlRequired": "網絡鉤手網址一定要輸入。", + "components.Settings.Notifications.validationWebhookUrlRequired": "網絡鉤手網址一定要輸入", "components.Settings.Notifications.NotificationsWebhook.webhookUrlPlaceholder": "網絡鉤手網址", "components.Settings.Notifications.NotificationsWebhook.webhookUrl": "網絡鉤手網址", - "components.Settings.Notifications.NotificationsWebhook.validationWebhookUrlRequired": "網絡鉤手網址一定要輸入。", + "components.Settings.Notifications.NotificationsWebhook.validationWebhookUrlRequired": "網絡鉤手網址一定要輸入", "components.Settings.Notifications.NotificationsSlack.webhookUrlPlaceholder": "網絡鉤手網址", "components.Settings.Notifications.NotificationsSlack.webhookUrl": "網絡鉤手網址", - "components.Settings.Notifications.NotificationsSlack.validationWebhookUrlRequired": "網絡鉤手網址一定要輸入。", + "components.Settings.Notifications.NotificationsSlack.validationWebhookUrlRequired": "網絡鉤手網址一定要輸入", "components.Settings.applicationurl": "Overseerr 網址", "components.Settings.SonarrModal.apiKey": "應用程式密鑰", "components.Settings.apikey": "應用程式密鑰", @@ -32,8 +32,8 @@ "components.Settings.Notifications.notificationtypes": "通知類型", "components.Settings.Notifications.NotificationsSlack.agentenabled": "啟用", "components.Settings.Notifications.NotificationsPushover.agentenabled": "啟用", - "components.Settings.Notifications.validationSmtpPortRequired": "SMTP 通訊埠一定要輸入。", - "components.Settings.Notifications.validationSmtpHostRequired": "SMTP 主機一定要輸入。", + "components.Settings.Notifications.validationSmtpPortRequired": "SMTP 通訊埠一定要輸入", + "components.Settings.Notifications.validationSmtpHostRequired": "SMTP 主機一定要輸入", "components.Settings.Notifications.smtpPort": "SMTP 通訊埠", "components.Settings.Notifications.smtpHost": "SMTP 主機", "i18n.partiallyavailable": "部分可觀看", @@ -42,7 +42,7 @@ "components.StatusChacker.newversionavailable": "軟體可更新", "components.Layout.alphawarning": "這是預覽版軟體,所以可能會不穩定或被破壞。請向 GitHub 報告問題!", "components.TitleCard.tvshow": "電視節目", - "components.StatusBadge.status4k": "4K {status}", + "components.StatusBadge.status4k": "4K 版 {status}", "components.Setup.tip": "提示", "components.Setup.welcome": "歡迎來到 Overseerr", "components.TvDetails.TvCast.fullseriescast": "演員", @@ -65,10 +65,10 @@ "components.Settings.SettingsAbout.Releases.viewchangelog": "查看變更日誌", "components.UserEdit.permissions": "用戶權限", "components.Settings.defaultPermissions": "默認用戶權限", - "components.UserList.validationpasswordminchars": "密碼太短-要求最少 8 個字符。", - "components.UserList.validationemailrequired": "必須輸入有效的電子郵件地址。", + "components.UserList.validationpasswordminchars": "密碼太短-要求最少 8 個字符", + "components.UserList.validationemailrequired": "必須輸入有效的電子郵件地址", "components.UserList.userlist": "用戶清單", - "components.Setup.loginwithplex": "使用 Plex 帳號登入", + "components.Setup.loginwithplex": "使用您的 Plex 帳戶", "components.Setup.finish": "完成配置", "components.Setup.continue": "繼續", "components.Setup.configureservices": "配置服務器", @@ -128,7 +128,7 @@ "components.Settings.RadarrModal.testing": "測試中...", "components.Settings.RadarrModal.loadingrootfolders": "載入中...", "components.Settings.RadarrModal.loadingprofiles": "載入中...", - "components.Settings.toastSettingsSuccess": "設置已保存。", + "components.Settings.toastSettingsSuccess": "設置保存成功!", "components.UserEdit.save": "保存", "components.Settings.save": "保存", "components.Settings.SonarrModal.save": "保存", @@ -136,11 +136,11 @@ "components.Settings.Notifications.save": "保存", "components.Settings.Notifications.NotificationsWebhook.save": "保存", "components.Settings.Notifications.NotificationsSlack.save": "保存", - "components.UserEdit.saving": "儲存中...", - "components.Settings.saving": "儲存中...", - "components.Settings.SonarrModal.saving": "儲存中...", - "components.Settings.RadarrModal.saving": "儲存中...", - "components.Settings.Notifications.saving": "儲存中...", + "components.UserEdit.saving": "保存中...", + "components.Settings.saving": "保存中...", + "components.Settings.SonarrModal.saving": "保存中...", + "components.Settings.RadarrModal.saving": "保存中...", + "components.Settings.Notifications.saving": "保存中...", "components.Settings.Notifications.NotificationsWebhook.saving": "保存中...", "components.Settings.Notifications.NotificationsSlack.saving": "保存中...", "components.Settings.Notifications.NotificationsSlack.notificationtypes": "通知類型", @@ -196,7 +196,7 @@ "components.RequestButton.approverequest": "批准請求", "components.RequestButton.approve4krequests": "批准 {requestCount} 個 4K 請求", "components.RequestBlock.seasons": "季數", - "components.PlexLoginButton.loginwithplex": "使用 Plex 帳號登入", + "components.PlexLoginButton.loginwithplex": "使用您的 Plex 帳戶", "components.PlexLoginButton.loggingin": "登入中...", "components.PlexLoginButton.loading": "載入中...", "components.PersonDetails.nobiography": "沒有傳記。", @@ -237,7 +237,7 @@ "components.MovieDetails.MovieCast.fullcast": "演員", "components.Login.validationpasswordrequired": "請輸入密碼", "components.Login.validationemailrequired": "請輸入電子郵件地址", - "components.Login.signinwithoverseerr": "使用 Overseerr 帳號登入", + "components.Login.signinwithoverseerr": "使用您的 Overseerr 帳戶", "components.Login.signinplex": "使用 Plex 帳號登入", "components.Login.password": "密碼", "components.Login.loginerror": "登入中出了點問題。", @@ -284,11 +284,11 @@ "components.UserList.password": "密碼", "components.UserList.usertype": "用戶類型", "i18n.movies": "電影", - "components.Setup.signinMessage": "首先,請使用您的 Plex 帳號登入", + "components.Setup.signinMessage": "首先,請使用您的 Plex 帳戶登入", "components.CollectionDetails.numberofmovies": "電影數:{count}", "components.CollectionDetails.movies": "電影", "components.UserEdit.request4kMovies": "提交 4K 電影的請求", - "components.Settings.Notifications.validationFromRequired": "發件人電子郵件地址一定要輸入。", + "components.Settings.Notifications.validationFromRequired": "發件人電子郵件地址一定要輸入", "components.UserEdit.request4k": "提交 4K 請求", "components.UserEdit.request": "提交請求", "components.UserEdit.managerequests": "請求管理", @@ -300,7 +300,7 @@ "components.TvDetails.status": "狀態", "components.Settings.SonarrModal.baseUrl": "網站根目錄", "components.Settings.RadarrModal.baseUrl": "網站根目錄", - "components.Settings.RadarrModal.apiKeyPlaceholder": "Radarr 應用程式密鑰", + "components.Settings.RadarrModal.apiKeyPlaceholder": "您的 Radarr 應用程式密鑰", "components.StatusChacker.reloadOverseerr": "重新啟動 Overseerr", "components.Settings.runnow": "執行", "components.Settings.notrunning": "未運行", @@ -337,20 +337,20 @@ "components.Settings.addsonarr": "添加 Sonarr 伺服器", "components.Settings.SonarrModal.server4k": "4K 伺服器", "components.Settings.SettingsAbout.supportoverseerr": "支持 Overseerr!", - "components.Settings.SonarrModal.validationProfileRequired": "質量必須設置。", - "components.Settings.RadarrModal.validationProfileRequired": "質量必須設置。", + "components.Settings.SonarrModal.validationProfileRequired": "質量必須設置", + "components.Settings.RadarrModal.validationProfileRequired": "質量必須設置", "components.Settings.SonarrModal.selectQualityProfile": "質量設定", "components.Settings.RadarrModal.selectQualityProfile": "質量設定", - "components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "最低狀態必須設置。", + "components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "最低狀態必須設置", "components.Settings.RadarrModal.selectMinimumAvailability": "最低狀態設定", - "components.Settings.SonarrModal.validationRootFolderRequired": "根目錄必須設置 。", + "components.Settings.SonarrModal.validationRootFolderRequired": "根目錄必須設置", "components.Settings.SonarrModal.selectRootFolder": "根目錄設定", "components.Settings.RadarrModal.selectRootFolder": "根目錄設定", - "components.Settings.RadarrModal.validationRootFolderRequired": "根目錄必須設置 。", - "components.Settings.Notifications.NotificationsSlack.slacksettingssaved": "Slack 通知設置已保存!", - "components.Settings.Notifications.NotificationsWebhook.webhooksettingssaved": "網絡鉤手通知設置已保存!", - "components.Settings.Notifications.discordsettingssaved": "Discord 通知設置已保存!", - "components.Settings.Notifications.emailsettingssaved": "電子郵件通知設置已保存!", + "components.Settings.RadarrModal.validationRootFolderRequired": "根目錄必須設置", + "components.Settings.Notifications.NotificationsSlack.slacksettingssaved": "Slack 通知設置保存成功!", + "components.Settings.Notifications.NotificationsWebhook.webhooksettingssaved": "網絡鉤手通知設置保存成功!", + "components.Settings.Notifications.discordsettingssaved": "Discord 通知設置保存成功!", + "components.Settings.Notifications.emailsettingssaved": "電子郵件通知設置保存成功!", "components.Settings.Notifications.chatId": "Chat ID", "components.Settings.Notifications.NotificationsPushover.pushoversettingsfailed": "Pushover 通知設置保存失敗!", "components.Settings.Notifications.settinguptelegram": "Telegram 通知設定", @@ -364,10 +364,10 @@ "components.Settings.SonarrModal.animerootfolder": "動漫根目錄", "components.Settings.SonarrModal.rootfolder": "根目錄", "components.Settings.RadarrModal.rootfolder": "根目錄", - "components.Settings.Notifications.NotificationsWebhook.resetPayload": "重置為默認有效負載", + "components.Settings.Notifications.NotificationsWebhook.resetPayload": "重置為默認", "components.Settings.Notifications.NotificationsWebhook.customJson": "JSON 有效負載", - "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON 有效負載已重置為默認負載。", - "components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "用戶令牌一定要輸入。", + "components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON 有效負載成功重置為默認負載。", + "components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "用戶令牌一定要輸入", "components.UserEdit.autoapproveSeriesDescription": "自動批准這用戶提交的電視節目請求。", "components.UserEdit.autoapproveMoviesDescription": "自動批准這用戶提交的電影請求。", "components.UserEdit.autoapproveDescription": "自動批准這用戶提交的請求。", @@ -375,15 +375,15 @@ "components.Settings.jobname": "作業名", "components.Settings.menuJobs": "作業", "components.Settings.toastApiKeyFailure": "生成應用程式密鑰出了點問題。", - "components.Settings.SonarrModal.apiKeyPlaceholder": "Sonarr 應用程式密鑰", + "components.Settings.SonarrModal.apiKeyPlaceholder": "您的 Sonarr 應用程式密鑰", "components.Settings.toastSettingsFailure": "保存設置中出了點問題。", "components.UserEdit.userfail": "用戶保存中出了點問題。", - "components.UserEdit.usersaved": "用戶已保存", + "components.UserEdit.usersaved": "用戶保存成功!", "components.UserList.deleteconfirm": "確定要刪除這個用戶嗎?由這個用戶的所有儲存資料將被清除。", "components.Settings.SettingsAbout.Releases.releasedataMissing": "無法獲取軟體版本資料。GitHub 崩潰了嗎?", "components.UserList.passwordinfodescription": "啟用電子郵件通知才能自動生成密碼。", "components.UserList.passwordinfo": "訊息", - "components.Settings.Notifications.validationBotAPIRequired": "Bot 機器人應用程式密鑰一定要輸入。", + "components.Settings.Notifications.validationBotAPIRequired": "Bot 機器人應用程式密鑰一定要輸入", "components.Settings.Notifications.botAPI": "Bot 機器人應用程式介面(API)", "components.Settings.menuServices": "服務器", "components.Settings.address": "網址", @@ -415,13 +415,13 @@ "components.Settings.SettingsAbout.Releases.versionChangelog": "變更日誌", "components.Settings.SettingsAbout.Releases.runningDevelop": "您正在運行 Overseerr 的開發版本。", "components.Settings.SettingsAbout.Releases.releases": "軟體版本", - "components.Slider.noresults": "沒有結果", + "components.Slider.noresults": "沒有結果。", "components.Settings.plexsettings": "Plex 設定", "components.RequestModal.selectseason": "季數選擇", "components.RequestModal.requesttitle": "為 {title} 提交請求", "components.RequestModal.requestseasons": "為 {seasonCount} 個季數提交請求", "components.RequestModal.requestadmin": "您的請求將自動被批准。", - "components.RequestModal.requestSuccess": "已為 {title} 提交請求。", + "components.RequestModal.requestSuccess": "成功為 {title} 提交請求。", "components.RequestModal.requestCancel": "{title} 的請求已被取消。", "components.RequestModal.request4ktitle": "為 {title} 提交 4K 請求", "components.PersonDetails.appearsin": "演出", @@ -430,50 +430,50 @@ "components.TvDetails.overviewunavailable": "沒有概要。", "components.MovieDetails.overviewunavailable": "沒有概要。", "components.TvDetails.watchtrailer": "觀看預告片", - "components.TvDetails.showtype": "類型", + "components.TvDetails.showtype": "節目類型", "components.TvDetails.similar": "類似", "components.TvDetails.similarsubtext": "類似 {title} 的電視節目", "components.UserEdit.avatar": "頭像", "components.UserEdit.request4kTv": "提交 4K 電視節目的請求", "components.RequestModal.requestfrom": "{username} 的請求待處理", - "components.RequestModal.request4kfrom": "{username} 的 4K 請求待處理", - "components.Settings.toastApiKeySuccess": "新的應用程式密鑰已生成!", + "components.RequestModal.request4kfrom": "{username} 的 4K 請求待處理。", + "components.Settings.toastApiKeySuccess": "生成新應用程式密鑰成功!", "components.Settings.SettingsAbout.clickheretojoindiscord": "加入 Overseerr 的 Discord 伺服器!", - "components.Settings.validationPortRequired": "通訊埠一定要輸入。", - "components.Settings.validationHostnameRequired": "主機名稱或 IP 位址一定要輸入。", - "components.Settings.SonarrModal.validationPortRequired": "通訊埠一定要輸入。", - "components.Settings.SonarrModal.validationNameRequired": "伺服器名稱一定要輸入。", - "components.Settings.SonarrModal.validationHostnameRequired": "主機名稱或 IP 位址一定要輸入。", - "components.Settings.RadarrModal.validationPortRequired": "通訊埠一定要輸入。", - "components.Settings.SonarrModal.validationApiKeyRequired": "應用程式密鑰一定要輸入。", - "components.Settings.RadarrModal.validationNameRequired": "伺服器名稱一定要輸入。", - "components.Settings.RadarrModal.validationHostnameRequired": "主機名稱或 IP 位址一定要輸入。", - "components.Settings.RadarrModal.validationApiKeyRequired": "應用程式密鑰一定要輸入。", - "components.Settings.Notifications.validationChatIdRequired": "Chat ID 一定要輸入。", - "components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "JSON 有效負載一定要輸入。", + "components.Settings.validationPortRequired": "通訊埠一定要輸入", + "components.Settings.validationHostnameRequired": "主機名稱或 IP 位址一定要輸入", + "components.Settings.SonarrModal.validationPortRequired": "通訊埠一定要輸入", + "components.Settings.SonarrModal.validationNameRequired": "伺服器名稱一定要輸入", + "components.Settings.SonarrModal.validationHostnameRequired": "主機名稱或 IP 位址一定要輸入", + "components.Settings.RadarrModal.validationPortRequired": "通訊埠一定要輸入", + "components.Settings.SonarrModal.validationApiKeyRequired": "應用程式密鑰一定要輸入", + "components.Settings.RadarrModal.validationNameRequired": "伺服器名稱一定要輸入", + "components.Settings.RadarrModal.validationHostnameRequired": "主機名稱或 IP 位址一定要輸入", + "components.Settings.RadarrModal.validationApiKeyRequired": "應用程式密鑰一定要輸入", + "components.Settings.Notifications.validationChatIdRequired": "Chat ID 一定要輸入", + "components.Settings.Notifications.NotificationsWebhook.validationJsonPayloadRequired": "JSON 有效負載一定要輸入", "components.Settings.Notifications.NotificationsWebhook.authheader": "Authorization 頭欄位", "components.Settings.RadarrModal.minimumAvailability": "最低狀態", "components.Settings.Notifications.allowselfsigned": "允許自簽名證書", - "components.Settings.Notifications.NotificationsPushover.validationAccessTokenRequired": "令牌一定要輸入。", + "components.Settings.Notifications.NotificationsPushover.validationAccessTokenRequired": "令牌一定要輸入", "components.Settings.RadarrModal.hostname": "主機名稱或 IP 位址", "components.Settings.SonarrModal.hostname": "主機名稱或 IP 位址", "components.Settings.hostname": "主機名稱或 IP 位址", - "components.Settings.RadarrModal.toastRadarrTestFailure": "Radarr 伺服器連線失敗!", + "components.Settings.RadarrModal.toastRadarrTestFailure": "Radarr 伺服器連線失敗。", "components.Settings.SonarrModal.toastRadarrTestFailure": "Sonarr 伺服器連線失敗!", "components.Settings.RadarrModal.server4k": "4K 伺服器", "components.Settings.SonarrModal.animequalityprofile": "動漫質量設置", "components.Settings.SonarrModal.qualityprofile": "質量設置", "components.Settings.RadarrModal.qualityprofile": "質量設置", "components.Settings.Notifications.telegramsettingsfailed": "Telegram 通知設置保存失敗!", - "components.Settings.Notifications.telegramsettingssaved": "Telegram 通知設置已保存!", - "components.Settings.Notifications.NotificationsPushover.pushoversettingssaved": "Pushover 通知設置已保存!", + "components.Settings.Notifications.telegramsettingssaved": "Telegram 通知設置保存成功!", + "components.Settings.Notifications.NotificationsPushover.pushoversettingssaved": "Pushover 通知設置保存成功!", "components.UserList.created": "已建立", "components.UserList.create": "建立", "components.Settings.SonarrModal.createsonarr": "建立 Sonarr 伺服器", "components.Settings.RadarrModal.createradarr": "建立 Radarr 伺服器", - "components.Settings.Notifications.webhookUrlPlaceholder": "伺服器設定>整合>Webhook", + "components.Settings.Notifications.webhookUrlPlaceholder": "伺服器設定 → 整合 → Webhooks", "components.Settings.servernamePlaceholder": "Plex 伺服器名稱", - "components.Settings.servername": "伺服器名稱(保存後自動設置)", + "components.Settings.servername": "伺服器名稱", "components.Settings.SonarrModal.servernamePlaceholder": "Sonarr 伺服器", "components.Settings.SonarrModal.servername": "伺服器名稱", "components.Settings.SonarrModal.editsonarr": "編輯 Sonarr 伺服器", @@ -487,7 +487,7 @@ "components.RequestModal.requestcancelled": "請求已被取消。", "components.RequestModal.AdvancedRequester.qualityprofile": "質量設置", "components.RequestModal.AdvancedRequester.animenote": "*這是個動漫節目。", - "components.RequestModal.AdvancedRequester.advancedoptions": "高級選項", + "components.RequestModal.AdvancedRequester.advancedoptions": "進階選項", "components.RequestModal.AdvancedRequester.default": "(默認)", "components.RequestModal.AdvancedRequester.destinationserver": "目標伺服器", "components.RequestBlock.server": "伺服器", @@ -495,5 +495,84 @@ "components.RequestBlock.rootfolder": "根目錄", "components.RequestModal.AdvancedRequester.loadingprofiles": "載入中...", "components.RequestModal.AdvancedRequester.loadingfolders": "載入中...", - "components.MediaSlider.ShowMoreCard.seemore": "更多" + "components.MediaSlider.ShowMoreCard.seemore": "更多", + "components.Login.signinwithplex": "使用您的 Plex 帳戶", + "components.Login.signinheader": "登入", + "components.Login.signingin": "登入中...", + "components.Login.signin": "登入", + "components.Settings.SonarrModal.toastSonarrTestFailure": "Sonarr 伺服器連線失敗。", + "components.Settings.serverpresetPlaceholder": "Plex 伺服器", + "components.Settings.serverpreset": "伺服器", + "components.Settings.servernameTip": "保存後自動設置", + "components.RequestModal.autoapproval": "自動批准", + "components.PermissionEdit.autoapproveSeries": "電視節目自動批准", + "components.PermissionEdit.autoapproveMovies": "電影自動批准", + "components.PermissionEdit.autoapprove": "自動批准", + "components.PermissionEdit.admin": "管理員", + "components.MovieDetails.areyousure": "確定嗎?", + "components.Common.ListView.noresults": "沒有結果。", + "components.Settings.toastPlexConnecting": "連線中...", + "components.Settings.toastPlexRefresh": "載入中...", + "components.Settings.serverpresetRefreshing": "載入中...", + "components.Settings.serverConnected": "已連上線", + "components.Settings.SonarrModal.syncEnabled": "啟用同步", + "components.Settings.SonarrModal.externalUrlPlaceholder": "Sonarr 伺服器的外部網址", + "components.Settings.RadarrModal.externalUrlPlaceholder": "Radarr 伺服器的外部網址", + "components.Settings.SonarrModal.preventSearch": "禁用自動搜索", + "components.Settings.notificationsettingssaved": "通知設置保存成功!", + "components.UserList.userssaved": "用戶保存成功!", + "components.Settings.jobcancelled": "{jobname} 已被取消。", + "components.Settings.hideAvailable": "隱藏可觀看的電影和電視節目", + "components.RequestModal.SearchByNameModal.notvdbid": "必須手動配對", + "components.Settings.RadarrModal.preventSearch": "禁用自動搜索", + "components.Settings.SonarrModal.externalUrl": "外部網址", + "components.Settings.RadarrModal.externalUrl": "外部網址", + "components.UserEdit.plexUsername": "Plex 用戶名", + "components.Settings.csrfProtection": "防止跨站請求偽造(CSRF)攻擊", + "components.RequestBlock.requestoverrides": "覆寫請求", + "components.Settings.toastPlexConnectingSuccess": "Plex 伺服器連線成功!", + "components.Settings.serverRemote": "遠端", + "components.Settings.serverLocal": "本地", + "components.TvDetails.opensonarr": "開啟 Sonarr 伺服器", + "components.MovieDetails.openradarr": "開啟 Radarr 伺服器", + "components.MovieDetails.mark4kavailable": "標記為 4K 可觀看", + "components.MovieDetails.markavailable": "標記為可觀看", + "components.TvDetails.downloadstatus": "下載狀態", + "components.TvDetails.areyousure": "確定嗎?", + "components.Settings.settingUpPlex": "設置 Plex", + "components.Settings.RadarrModal.syncEnabled": "啟用同步", + "i18n.experimental": "實驗性", + "components.UserList.bulkedit": "批量編輯", + "i18n.edit": "編輯", + "components.Settings.timeout": "超時", + "components.Settings.serverpresetManualMessage": "手動設定", + "components.Settings.jobtype": "作業類型", + "components.Settings.enablenotifications": "啟用通知", + "components.NotificationTypeSelector.mediadeclined": "請求被拒絕", + "components.TvDetails.playonplex": "在 Plex 上觀看", + "components.TvDetails.play4konplex": "在 Plex 上觀看 4K 版", + "components.TvDetails.opensonarr4k": "開啟 4K Sonarr 伺服器", + "components.MovieDetails.openradarr4k": "開啟 4K Radarr 伺服器", + "components.MovieDetails.play4konplex": "在 Plex 上觀看 4K 版", + "components.MovieDetails.playonplex": "在 Plex 上觀看", + "components.RequestModal.backbutton": "返回", + "components.PlexLoginButton.signinwithplex": "登入", + "components.RequestModal.next": "下一頁", + "components.RequestModal.SearchByNameModal.next": "下一頁", + "components.PlexLoginButton.signingin": "登入中...", + "components.PermissionEdit.vote": "投票", + "components.PermissionEdit.users": "用戶管理", + "components.PermissionEdit.settings": "設置管理", + "components.PermissionEdit.request4kTv": "提交 4K 電視節目請求", + "components.PermissionEdit.request4kMovies": "提交 4K 電影請求", + "components.PermissionEdit.request4k": "提交 4K 請求", + "components.PermissionEdit.request": "提交請求", + "components.PermissionEdit.managerequests": "請求管理", + "components.MovieDetails.downloadstatus": "下載狀態", + "components.RequestBlock.profilechanged": "質量設置更改成功!", + "components.Settings.jobstarted": "{jobname} 已開始運行。", + "components.Settings.canceljob": "取消", + "components.PermissionEdit.advancedrequest": "進階請求", + "components.RequestModal.requestedited": "請求編輯成功!", + "components.Settings.trustProxy": "啟用代理伺服器所需功能" } diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 5af86ac6..2009f560 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -38,6 +38,8 @@ const loadLocaleData = (locale: AvailableLocales): Promise => { return import('../i18n/locale/it.json'); case 'pt-BR': return import('../i18n/locale/pt_BR.json'); + case 'pt-PT': + return import('../i18n/locale/pt_PT.json'); case 'sr': return import('../i18n/locale/sr.json'); case 'sv': @@ -135,6 +137,7 @@ CoreApp.getInitialProps = async (initialProps) => { let user = undefined; let currentSettings: PublicSettingsResponse = { initialized: false, + hideAvailable: false, movie4kEnabled: false, series4kEnabled: false, }; diff --git a/src/pages/_error.tsx b/src/pages/_error.tsx index 69463a9f..b40c501a 100644 --- a/src/pages/_error.tsx +++ b/src/pages/_error.tsx @@ -9,8 +9,8 @@ interface ErrorProps { } const messages = defineMessages({ - internalServerError: '{statusCode} - Internal Server Error', - serviceUnavailable: '{statusCode} - Service Unavailable', + internalServerError: '{statusCode} - Internal server error', + serviceUnavailable: '{statusCode} - Service unavailable', somethingWentWrong: '{statusCode} - Something went wrong', oops: 'Oops', returnHome: 'Return Home', diff --git a/tailwind.config.js b/tailwind.config.js index fbb20f72..8f821bb5 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -63,6 +63,7 @@ module.exports = { margin: ['first', 'last', 'responsive'], boxShadow: ['group-focus'], opacity: ['disabled', 'hover', 'group-hover'], + zIndex: ['hover', 'responsive'], }, plugins: [ require('@tailwindcss/forms'), diff --git a/yarn.lock b/yarn.lock index 1a30c594..3254f9e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1806,6 +1806,11 @@ resolved "https://registry.yarnpkg.com/@sqltools/formatter/-/formatter-1.2.2.tgz#9390a8127c0dcba61ebd7fdcc748655e191bdd68" integrity sha512-/5O7Fq6Vnv8L6ucmPjaWbVG1XkP4FO+w5glqfkIsq3Xw4oyNAdJddbnYodNDAfjVUvo/rrSCTom4kAND7T1o5Q== +"@supercharge/request-ip@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@supercharge/request-ip/-/request-ip-1.1.2.tgz#be9083aa50d3c6fc200f3ed5919e0b9c13fc8842" + integrity sha512-mtryG/uiSIVT0ga8A/F9hNEuBSbDxW7/m9PEtxIHqdkE/vr766m17mtLPhrXA4q4T+Qw44+33mg3Rtkl7o+OvQ== + "@svgr/babel-plugin-add-jsx-attribute@^5.4.0": version "5.4.0" resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz#81ef61947bb268eb9d50523446f9c638fb355906" @@ -2008,6 +2013,13 @@ dependencies: "@types/express" "*" +"@types/csurf@^1.11.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@types/csurf/-/csurf-1.11.0.tgz#2809e89f55f12a2df8cd2826c06dfd66600dd14d" + integrity sha512-IwqGRWImcbIdwumGprYR0EgIZ7GAklOIGaNqe2u7jb0YUilg7yrrxXth11VA/AJK8wQWYHxTQagkCE75oaoBvQ== + dependencies: + "@types/express-serve-static-core" "*" + "@types/debug@0.0.31": version "0.0.31" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-0.0.31.tgz#bac8d8aab6a823e91deb7f79083b2a35fa638f33" @@ -2121,10 +2133,10 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.165.tgz#74d55d947452e2de0742bad65270433b63a8c30f" integrity sha512-tjSSOTHhI5mCHTy/OOXYIhi2Wt1qcbHmuXD1Ha7q70CgI/I71afO4XtLb/cVexki1oVYchpul/TOuu3Arcdxrg== -"@types/lodash@^4.14.167": - version "4.14.167" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.167.tgz#ce7d78553e3c886d4ea643c37ec7edc20f16765e" - integrity sha512-w7tQPjARrvdeBkX/Rwg95S592JwxqOjmms3zWQ0XZgSyxSLdzWaYH3vErBhdVS/lRBX7F8aBYcYJYTr5TMGOzw== +"@types/lodash@^4.14.168": + version "4.14.168" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.168.tgz#fe24632e79b7ade3f132891afff86caa5e5ce008" + integrity sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q== "@types/mdast@^3.0.0", "@types/mdast@^3.0.3": version "3.0.3" @@ -2165,10 +2177,10 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.10.tgz#5958a82e41863cfc71f2307b3748e3491ba03785" integrity sha512-J32dgx2hw8vXrSbu4ZlVhn1Nm3GbeCFNw2FWL8S5QKucHGY0cyNwjdQdO+KMBZ4wpmC7KhLCiNsdk1RFRIYUQQ== -"@types/node@^14.14.21": - version "14.14.21" - resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.21.tgz#d934aacc22424fe9622ebf6857370c052eae464e" - integrity sha512-cHYfKsnwllYhjOzuC5q1VpguABBeecUp24yFluHpn/BQaVxB1CuQ1FSRZCzrPxrkIfWISXV2LbeoBthLWg0+0A== +"@types/node@^14.14.22": + version "14.14.22" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.22.tgz#0d29f382472c4ccf3bd96ff0ce47daf5b7b84b18" + integrity sha512-g+f/qj/cNcqKkc3tFqlXOYjrmZA+jNBiDzbP3kH+B+otKFqAdPgVTGP1IeKRdMml/aE69as5S4FqtxAbl+LaMw== "@types/nodemailer@*", "@types/nodemailer@^6.4.0": version "6.4.0" @@ -2287,10 +2299,10 @@ resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f" integrity sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ== -"@types/xml2js@^0.4.7": - version "0.4.7" - resolved "https://registry.yarnpkg.com/@types/xml2js/-/xml2js-0.4.7.tgz#cd5b6c67bbec741ac625718a76e6cb99bc34365e" - integrity sha512-f5VOKSMEE0O+/L54FHwA/a7vcx9mHeSDM71844yHCOhh8Cin2xQa0UFw0b7Vc5hoZ3Ih6ZHaDobjfLih4tWPNw== +"@types/xml2js@^0.4.8": + version "0.4.8" + resolved "https://registry.yarnpkg.com/@types/xml2js/-/xml2js-0.4.8.tgz#84c120c864a5976d0b5cf2f930a75d850fc2b03a" + integrity sha512-EyvT83ezOdec7BhDaEcsklWy7RSIdi6CNe95tmOAK0yx/Lm30C9K75snT3fYayK59ApC2oyW+rcHErdG05FHJA== dependencies: "@types/node" "*" @@ -2304,13 +2316,13 @@ resolved "https://registry.yarnpkg.com/@types/yup/-/yup-0.29.11.tgz#d654a112973f5e004bf8438122bd7e56a8e5cd7e" integrity sha512-9cwk3c87qQKZrT251EDoibiYRILjCmxBvvcb4meofCmx1vdnNcR9gyildy5vOHASpOKMsn42CugxUvcwK5eu1g== -"@typescript-eslint/eslint-plugin@^4.13.0": - version "4.13.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.13.0.tgz#5f580ea520fa46442deb82c038460c3dd3524bb6" - integrity sha512-ygqDUm+BUPvrr0jrXqoteMqmIaZ/bixYOc3A4BRwzEPTZPi6E+n44rzNZWaB0YvtukgP+aoj0i/fyx7FkM2p1w== +"@typescript-eslint/eslint-plugin@^4.14.0": + version "4.14.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.14.0.tgz#92db8e7c357ed7d69632d6843ca70b71be3a721d" + integrity sha512-IJ5e2W7uFNfg4qh9eHkHRUCbgZ8VKtGwD07kannJvM5t/GU8P8+24NX8gi3Hf5jST5oWPY8kyV1s/WtfiZ4+Ww== dependencies: - "@typescript-eslint/experimental-utils" "4.13.0" - "@typescript-eslint/scope-manager" "4.13.0" + "@typescript-eslint/experimental-utils" "4.14.0" + "@typescript-eslint/scope-manager" "4.14.0" debug "^4.1.1" functional-red-black-tree "^1.0.1" lodash "^4.17.15" @@ -2318,53 +2330,53 @@ semver "^7.3.2" tsutils "^3.17.1" -"@typescript-eslint/experimental-utils@4.13.0": - version "4.13.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.13.0.tgz#9dc9ab375d65603b43d938a0786190a0c72be44e" - integrity sha512-/ZsuWmqagOzNkx30VWYV3MNB/Re/CGv/7EzlqZo5RegBN8tMuPaBgNK6vPBCQA8tcYrbsrTdbx3ixMRRKEEGVw== +"@typescript-eslint/experimental-utils@4.14.0": + version "4.14.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.14.0.tgz#5aa7b006736634f588a69ee343ca959cd09988df" + integrity sha512-6i6eAoiPlXMKRbXzvoQD5Yn9L7k9ezzGRvzC/x1V3650rUk3c3AOjQyGYyF9BDxQQDK2ElmKOZRD0CbtdkMzQQ== dependencies: "@types/json-schema" "^7.0.3" - "@typescript-eslint/scope-manager" "4.13.0" - "@typescript-eslint/types" "4.13.0" - "@typescript-eslint/typescript-estree" "4.13.0" + "@typescript-eslint/scope-manager" "4.14.0" + "@typescript-eslint/types" "4.14.0" + "@typescript-eslint/typescript-estree" "4.14.0" eslint-scope "^5.0.0" eslint-utils "^2.0.0" -"@typescript-eslint/parser@^4.13.0": - version "4.13.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.13.0.tgz#c413d640ea66120cfcc37f891e8cb3fd1c9d247d" - integrity sha512-KO0J5SRF08pMXzq9+abyHnaGQgUJZ3Z3ax+pmqz9vl81JxmTTOUfQmq7/4awVfq09b6C4owNlOgOwp61pYRBSg== +"@typescript-eslint/parser@^4.14.0": + version "4.14.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.14.0.tgz#62d4cd2079d5c06683e9bfb200c758f292c4dee7" + integrity sha512-sUDeuCjBU+ZF3Lzw0hphTyScmDDJ5QVkyE21pRoBo8iDl7WBtVFS+WDN3blY1CH3SBt7EmYCw6wfmJjF0l/uYg== dependencies: - "@typescript-eslint/scope-manager" "4.13.0" - "@typescript-eslint/types" "4.13.0" - "@typescript-eslint/typescript-estree" "4.13.0" + "@typescript-eslint/scope-manager" "4.14.0" + "@typescript-eslint/types" "4.14.0" + "@typescript-eslint/typescript-estree" "4.14.0" debug "^4.1.1" -"@typescript-eslint/scope-manager@4.13.0": - version "4.13.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.13.0.tgz#5b45912a9aa26b29603d8fa28f5e09088b947141" - integrity sha512-UpK7YLG2JlTp/9G4CHe7GxOwd93RBf3aHO5L+pfjIrhtBvZjHKbMhBXTIQNkbz7HZ9XOe++yKrXutYm5KmjWgQ== +"@typescript-eslint/scope-manager@4.14.0": + version "4.14.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.14.0.tgz#55a4743095d684e1f7b7180c4bac2a0a3727f517" + integrity sha512-/J+LlRMdbPh4RdL4hfP1eCwHN5bAhFAGOTsvE6SxsrM/47XQiPSgF5MDgLyp/i9kbZV9Lx80DW0OpPkzL+uf8Q== dependencies: - "@typescript-eslint/types" "4.13.0" - "@typescript-eslint/visitor-keys" "4.13.0" + "@typescript-eslint/types" "4.14.0" + "@typescript-eslint/visitor-keys" "4.14.0" "@typescript-eslint/types@3.10.1": version "3.10.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-3.10.1.tgz#1d7463fa7c32d8a23ab508a803ca2fe26e758727" integrity sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ== -"@typescript-eslint/types@4.13.0": - version "4.13.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.13.0.tgz#6a7c6015a59a08fbd70daa8c83dfff86250502f8" - integrity sha512-/+aPaq163oX+ObOG00M0t9tKkOgdv9lq0IQv/y4SqGkAXmhFmCfgsELV7kOCTb2vVU5VOmVwXBXJTDr353C1rQ== +"@typescript-eslint/types@4.14.0": + version "4.14.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.14.0.tgz#d8a8202d9b58831d6fd9cee2ba12f8a5a5dd44b6" + integrity sha512-VsQE4VvpldHrTFuVPY1ZnHn/Txw6cZGjL48e+iBxTi2ksa9DmebKjAeFmTVAYoSkTk7gjA7UqJ7pIsyifTsI4A== -"@typescript-eslint/typescript-estree@4.13.0": - version "4.13.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.13.0.tgz#cf6e2207c7d760f5dfd8d18051428fadfc37b45e" - integrity sha512-9A0/DFZZLlGXn5XA349dWQFwPZxcyYyCFX5X88nWs2uachRDwGeyPz46oTsm9ZJE66EALvEns1lvBwa4d9QxMg== +"@typescript-eslint/typescript-estree@4.14.0": + version "4.14.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.14.0.tgz#4bcd67486e9acafc3d0c982b23a9ab8ac8911ed7" + integrity sha512-wRjZ5qLao+bvS2F7pX4qi2oLcOONIB+ru8RGBieDptq/SudYwshveORwCVU4/yMAd4GK7Fsf8Uq1tjV838erag== dependencies: - "@typescript-eslint/types" "4.13.0" - "@typescript-eslint/visitor-keys" "4.13.0" + "@typescript-eslint/types" "4.14.0" + "@typescript-eslint/visitor-keys" "4.14.0" debug "^4.1.1" globby "^11.0.1" is-glob "^4.0.1" @@ -2393,12 +2405,12 @@ dependencies: eslint-visitor-keys "^1.1.0" -"@typescript-eslint/visitor-keys@4.13.0": - version "4.13.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.13.0.tgz#9acb1772d3b3183182b6540d3734143dce9476fe" - integrity sha512-6RoxWK05PAibukE7jElqAtNMq+RWZyqJ6Q/GdIxaiUj2Ept8jh8+FUVlbq9WxMYxkmEOPvCE5cRSyupMpwW31g== +"@typescript-eslint/visitor-keys@4.14.0": + version "4.14.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.14.0.tgz#b1090d9d2955b044b2ea2904a22496849acbdf54" + integrity sha512-MeHHzUyRI50DuiPgV9+LxcM52FCJFYjJiWHtXlbyC27b80mfOwKeiKI+MHOTEpcpfmoPFm/vvQS88bYIx6PZTA== dependencies: - "@typescript-eslint/types" "4.13.0" + "@typescript-eslint/types" "4.14.0" eslint-visitor-keys "^2.0.0" "@webassemblyjs/ast@1.9.0": @@ -3267,13 +3279,6 @@ bl@^4.0.3: inherits "^2.0.4" readable-stream "^3.4.0" -block-stream@*: - version "0.0.9" - resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" - integrity sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo= - dependencies: - inherits "~2.0.0" - bluebird@^3.3.5, bluebird@^3.5.0, bluebird@^3.5.1, bluebird@^3.5.3, bluebird@^3.5.5, bluebird@^3.7.2: version "3.7.2" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" @@ -3876,7 +3881,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@2.2.6: +classnames@2.2.6, classnames@^2.2.5: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== @@ -4613,7 +4618,7 @@ cross-spawn@^6.0.0: shebang-command "^1.2.0" which "^1.2.9" -cross-spawn@^7.0.0, cross-spawn@^7.0.2: +cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== @@ -4649,6 +4654,15 @@ crypto-random-string@^2.0.0: resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== +csrf@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/csrf/-/csrf-3.1.0.tgz#ec75e9656d004d674b8ef5ba47b41fbfd6cb9c30" + integrity sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w== + dependencies: + rndm "1.2.0" + tsscmp "1.0.6" + uid-safe "2.1.5" + css-blank-pseudo@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/css-blank-pseudo/-/css-blank-pseudo-0.1.4.tgz#dfdefd3254bf8a82027993674ccf35483bfcb3c5" @@ -4824,6 +4838,16 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.2.tgz#ee5ff8f208c8cd613b389f7b222c9801ca62b3f7" integrity sha512-ofovWglpqoqbfLNOTBNZLSbMuGrblAf1efvvArGKOZMBrIoJeu5UsAipQolkijtyQx5MtAzT/J9IHj/CEY1mJw== +csurf@^1.11.0: + version "1.11.0" + resolved "https://registry.yarnpkg.com/csurf/-/csurf-1.11.0.tgz#ab0c3c6634634192bd3d6f4b861be20800eeb61a" + integrity sha512-UCtehyEExKTxgiu8UHdGvHj4tnpE/Qctue03Giq5gPgMQ9cg/ciod5blZQ5a4uCEenNQjxyGuzygLdKUmee/bQ== + dependencies: + cookie "0.4.0" + cookie-signature "1.0.6" + csrf "3.1.0" + http-errors "~1.7.3" + cyclist@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" @@ -5668,10 +5692,10 @@ escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.5: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= -eslint-config-prettier@^7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-7.1.0.tgz#5402eb559aa94b894effd6bddfa0b1ca051c858f" - integrity sha512-9sm5/PxaFG7qNJvJzTROMM1Bk1ozXVTKI0buKOyb0Bsr1hrwi0H/TzxF/COtf1uxikIK8SwhX7K6zg78jAzbeA== +eslint-config-prettier@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-7.2.0.tgz#f4a4bd2832e810e8cc7c1411ec85b3e85c0c53f9" + integrity sha512-rV4Qu0C3nfJKPOAhFujFxB7RMP+URFyQqqOZW9DMRD7ZDTFyjaIlETU3xzHELt++4ugC0+Jm084HQYkkJe+Ivg== eslint-plugin-formatjs@^2.10.3: version "2.10.3" @@ -5932,6 +5956,21 @@ execa@^4.0.0, execa@^4.1.0: signal-exit "^3.0.2" strip-final-newline "^2.0.0" +execa@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-5.0.0.tgz#4029b0007998a841fbd1032e5f4de86a3c1e3376" + integrity sha512-ov6w/2LCiuyO4RLYGdpFGjkcs0wMTgGE8PrkTHikeUy5iJekXyPIKUjifk5CsE0pt7sMCrMZ3YNqoCj6idQOnQ== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.0" + human-signals "^2.1.0" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.1" + onetime "^5.1.2" + signal-exit "^3.0.3" + strip-final-newline "^2.0.0" + expand-brackets@^2.1.4: version "2.1.4" resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" @@ -6494,16 +6533,6 @@ fsevents@~2.1.2: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e" integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ== -fstream@^1.0.0, fstream@^1.0.12: - version "1.0.12" - resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045" - integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg== - dependencies: - graceful-fs "^4.1.2" - inherits "~2.0.0" - mkdirp ">=0.5 0" - rimraf "2" - function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -6610,6 +6639,11 @@ get-stream@^5.0.0, get-stream@^5.1.0: dependencies: pump "^3.0.0" +get-stream@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.0.tgz#3e0012cb6827319da2706e601a1583e8629a6718" + integrity sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg== + get-value@^2.0.3, get-value@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" @@ -6682,7 +6716,7 @@ glob@7.1.4: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^7.0.0, glob@^7.0.3, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: +glob@^7.0.0, glob@^7.0.5, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: version "7.1.6" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== @@ -7045,7 +7079,7 @@ http-errors@1.7.2: statuses ">= 1.5.0 < 2" toidentifier "1.0.0" -http-errors@1.7.3, http-errors@~1.7.2: +http-errors@1.7.3, http-errors@~1.7.2, http-errors@~1.7.3: version "1.7.3" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06" integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw== @@ -7108,6 +7142,11 @@ human-signals@^1.1.1: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw== +human-signals@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" + integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== + humanize-ms@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed" @@ -7268,7 +7307,7 @@ inflight@^1.0.4, inflight@~1.0.6: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3, inherits@~2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -9142,7 +9181,7 @@ mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== -"mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.5, mkdirp@~0.5.0, mkdirp@~0.5.1: +mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.5, mkdirp@~0.5.0, mkdirp@~0.5.1: version "0.5.5" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== @@ -9449,25 +9488,7 @@ node-fetch@2.6.1, node-fetch@^2.6.0, node-fetch@^2.6.1: resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== -node-gyp@3.x: - version "3.8.0" - resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.8.0.tgz#540304261c330e80d0d5edce253a68cb3964218c" - integrity sha512-3g8lYefrRRzvGeSowdJKAKyks8oUpLEd/DyPV4eMhVlhJ0aNaZqIrNUIPuEWWTAoPqyFkfGrM67MC69baqn6vA== - dependencies: - fstream "^1.0.0" - glob "^7.0.3" - graceful-fs "^4.1.2" - mkdirp "^0.5.0" - nopt "2 || 3" - npmlog "0 || 1 || 2 || 3 || 4" - osenv "0" - request "^2.87.0" - rimraf "2" - semver "~5.3.0" - tar "^2.0.0" - which "1" - -node-gyp@^5.0.2, node-gyp@^5.1.0: +node-gyp@3.x, node-gyp@^5.0.2, node-gyp@^5.1.0: version "5.1.1" resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-5.1.1.tgz#eb915f7b631c937d282e33aed44cb7a025f62a3e" integrity sha512-WH0WKGi+a4i4DUt2mHnvocex/xPLp9pYt5R6M2JdFB7pJ7Z34hveZ4nDTGTiLXCkitA9T8HFZjhinBCiVHYcWw== @@ -9605,10 +9626,10 @@ noms@0.0.0: inherits "^2.0.1" readable-stream "~1.0.31" -nookies@^2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/nookies/-/nookies-2.5.1.tgz#f6988a3b0abb4378b710a75860fe247e873bc666" - integrity sha512-5zKrA+KpwlzxohICzdzrll3+Er0yBZvRgkIwXDUYE7U7G2KSw3Npir+oo+z5ahwMRyCnpv8G/YLYh+oarllSrQ== +nookies@^2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/nookies/-/nookies-2.5.2.tgz#cc55547efa982d013a21475bd0db0c02c1b35b27" + integrity sha512-x0TRSaosAEonNKyCrShoUaJ5rrT5KHRNZ5DwPCuizjgrnkpE5DRf3VL7AyyQin4htict92X1EQ7ejDbaHDVdYA== dependencies: cookie "^0.4.1" set-cookie-parser "^2.4.6" @@ -9618,13 +9639,6 @@ noop-logger@^0.1.1: resolved "https://registry.yarnpkg.com/noop-logger/-/noop-logger-0.1.1.tgz#94a2b1633c4f1317553007d8966fd0e841b6a4c2" integrity sha1-lKKxYzxPExdVMAfYlm/Q6EG2pMI= -"nopt@2 || 3": - version "3.0.6" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9" - integrity sha1-xkZdvwirzU2zWTF/eaxopkayj/k= - dependencies: - abbrev "1" - nopt@^4.0.1, nopt@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.3.tgz#a375cad9d02fd921278d954c2254d5aa57e15e48" @@ -9800,7 +9814,7 @@ npm-run-path@^2.0.0: dependencies: path-key "^2.0.0" -npm-run-path@^4.0.0: +npm-run-path@^4.0.0, npm-run-path@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== @@ -9933,7 +9947,7 @@ npm@^6.14.8: worker-farm "^1.7.0" write-file-atomic "^2.4.3" -"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.1, npmlog@^4.0.2, npmlog@^4.1.2, npmlog@~4.1.2: +npmlog@^4.0.1, npmlog@^4.0.2, npmlog@^4.1.2, npmlog@~4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== @@ -10112,7 +10126,7 @@ onetime@^2.0.0: dependencies: mimic-fn "^1.0.0" -onetime@^5.1.0: +onetime@^5.1.0, onetime@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== @@ -10180,7 +10194,7 @@ os-tmpdir@^1.0.0, os-tmpdir@~1.0.2: resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= -osenv@0, osenv@^0.1.4, osenv@^0.1.5: +osenv@^0.1.4, osenv@^0.1.5: version "0.1.5" resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410" integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g== @@ -11194,7 +11208,7 @@ promzard@^0.3.0: dependencies: read "1" -prop-types@15.7.2, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@15.7.2, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -11545,6 +11559,14 @@ react-ace@^9.2.1: lodash.isequal "^4.5.0" prop-types "^15.7.2" +react-animate-height@^2.0.23: + version "2.0.23" + resolved "https://registry.yarnpkg.com/react-animate-height/-/react-animate-height-2.0.23.tgz#2e14ac707b20ae67b87766ccfd581e693e0e7ec7" + integrity sha512-DucSC/1QuxWEFzR9IsHMzrf2nrcZ6qAmLIFoENa2kLK7h72XybcMA9o073z7aHccFzdMEW0/fhAdnQG7a4rDow== + dependencies: + classnames "^2.2.5" + prop-types "^15.6.1" + react-dom@17.0.1: version "17.0.1" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-17.0.1.tgz#1de2560474ec9f0e334285662ede52dbc5426fc6" @@ -12170,7 +12192,7 @@ rework@1.0.1: convert-source-map "^0.3.3" css "^2.0.0" -rimraf@2, rimraf@^2.5.2, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3, rimraf@^2.7.1: +rimraf@^2.5.2, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3, rimraf@^2.7.1: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== @@ -12192,6 +12214,11 @@ ripemd160@^2.0.0, ripemd160@^2.0.1: hash-base "^3.0.0" inherits "^2.0.1" +rndm@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/rndm/-/rndm-1.2.0.tgz#f33fe9cfb52bbfd520aa18323bc65db110a1b76c" + integrity sha1-8z/pz7Urv9UgqhgyO8ZdsRCht2w= + run-async@^2.2.0: version "2.4.1" resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455" @@ -12321,10 +12348,10 @@ semantic-release-docker@^2.2.0: "@semantic-release/error" "^2.1.0" execa "^0.10.0" -semantic-release@^17.3.3: - version "17.3.3" - resolved "https://registry.yarnpkg.com/semantic-release/-/semantic-release-17.3.3.tgz#8f3d2909e9570d9053ef195c138d8d15d7fe5815" - integrity sha512-StUBghTuh98O0XpDWj5aRLmZwY9zV6gGgbXnw5faon3Br1fRk8r2VSaYozfQTx8o5C8Mtem+a0KWEiTTd4Iylw== +semantic-release@^17.3.6: + version "17.3.6" + resolved "https://registry.yarnpkg.com/semantic-release/-/semantic-release-17.3.6.tgz#0390eb9c4d13e82dbf0f8e5700f196b599fa2a12" + integrity sha512-zPAFxmnMtEVbN1Lzxz+CMXlt5a9txB/PRWaVq+oAC9Mppbax/vWXZ0kisHX92O+BjEBbsaFtISjz82E+2Ro9gQ== dependencies: "@semantic-release/commit-analyzer" "^8.0.0" "@semantic-release/error" "^2.2.0" @@ -12332,10 +12359,10 @@ semantic-release@^17.3.3: "@semantic-release/npm" "^7.0.0" "@semantic-release/release-notes-generator" "^9.0.0" aggregate-error "^3.0.0" - cosmiconfig "^6.0.0" + cosmiconfig "^7.0.0" debug "^4.0.0" env-ci "^5.0.0" - execa "^4.0.0" + execa "^5.0.0" figures "^3.0.0" find-versions "^4.0.0" get-stream "^5.0.0" @@ -12353,7 +12380,7 @@ semantic-release@^17.3.3: semver "^7.3.2" semver-diff "^3.1.1" signale "^1.2.1" - yargs "^15.0.1" + yargs "^16.2.0" semver-compare@^1.0.0: version "1.0.0" @@ -12406,11 +12433,6 @@ semver@^7.1.2: dependencies: lru-cache "^6.0.0" -semver@~5.3.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" - integrity sha1-myzl094C0XxgEq0yaqa00M9U+U8= - send@0.17.1: version "0.17.1" resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8" @@ -12549,7 +12571,7 @@ side-channel@^1.0.2: es-abstract "^1.17.0-next.1" object-inspect "^1.7.0" -signal-exit@^3.0.0, signal-exit@^3.0.2: +signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== @@ -13321,15 +13343,6 @@ tar-stream@^2.1.4: inherits "^2.0.3" readable-stream "^3.1.1" -tar@^2.0.0: - version "2.2.2" - resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.2.tgz#0ca8848562c7299b8b446ff6a4d60cdbb23edc40" - integrity sha512-FCEhQ/4rE1zYv9rYXJw/msRqsnmlje5jHP6huWeBZ704jUTy02c5AZyWujpMR1ax6mVw9NyJMfuK2CMDWVIfgA== - dependencies: - block-stream "*" - fstream "^1.0.12" - inherits "2" - tar@^4, tar@^4.4.10, tar@^4.4.12, tar@^4.4.13, tar@^4.4.2: version "4.4.13" resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525" @@ -13646,6 +13659,11 @@ tslib@^2.0.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.2.tgz#462295631185db44b21b1ea3615b63cd1c038242" integrity sha512-wAH28hcEKwna96/UacuWaVspVLkg4x1aDM9JlzqaQTOFczCktkVAb5fmXChgandR1EraDPs2w8P+ozM+oafwxg== +tsscmp@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb" + integrity sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA== + tsutils@^3.17.1: version "3.17.1" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759" @@ -13789,7 +13807,7 @@ uid-number@0.0.6: resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81" integrity sha1-DqEOgDXo61uOREnwbaHHMGY7qoE= -uid-safe@~2.1.5: +uid-safe@2.1.5, uid-safe@~2.1.5: version "2.1.5" resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.5.tgz#2b3d5c7240e8fc2e58f8aa269e5ee49c0857bd3a" integrity sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA== @@ -14303,7 +14321,7 @@ which-pm-runs@^1.0.0: resolved "https://registry.yarnpkg.com/which-pm-runs/-/which-pm-runs-1.0.0.tgz#670b3afbc552e0b55df6b7780ca74615f23ad1cb" integrity sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs= -which@1, which@^1.2.14, which@^1.2.9, which@^1.3.0, which@^1.3.1: +which@^1.2.14, which@^1.2.9, which@^1.3.0, which@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== @@ -14622,7 +14640,7 @@ yargs@^14.2.3: y18n "^4.0.0" yargs-parser "^15.0.1" -yargs@^15.0.1, yargs@^15.1.0: +yargs@^15.1.0: version "15.4.1" resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== @@ -14639,7 +14657,7 @@ yargs@^15.0.1, yargs@^15.1.0: y18n "^4.0.0" yargs-parser "^18.1.2" -yargs@^16.0.0, yargs@^16.0.3, yargs@^16.1.0: +yargs@^16.0.0, yargs@^16.0.3, yargs@^16.1.0, yargs@^16.2.0: version "16.2.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==