diff --git a/.all-contributorsrc b/.all-contributorsrc index be1261d0..5b8d00db 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -364,7 +364,8 @@ "avatar_url": "https://avatars.githubusercontent.com/u/77843475?v=4", "profile": "https://github.com/HubDuck", "contributions": [ - "translation" + "translation", + "doc" ] }, { @@ -376,6 +377,60 @@ "doc", "translation" ] + }, + { + "login": "Shjosan", + "name": "Shjosan", + "avatar_url": "https://avatars.githubusercontent.com/u/20847626?v=4", + "profile": "https://github.com/Shjosan", + "contributions": [ + "translation" + ] + }, + { + "login": "kobaubarr", + "name": "kobaubarr", + "avatar_url": "https://avatars.githubusercontent.com/u/28481522?v=4", + "profile": "https://github.com/kobaubarr", + "contributions": [ + "translation" + ] + }, + { + "login": "notorius28", + "name": "Ricardo González", + "avatar_url": "https://avatars.githubusercontent.com/u/1621513?v=4", + "profile": "https://github.com/notorius28", + "contributions": [ + "translation" + ] + }, + { + "login": "Torkiliuz", + "name": "Torkil", + "avatar_url": "https://avatars.githubusercontent.com/u/460764?v=4", + "profile": "http://torkili.uz", + "contributions": [ + "translation" + ] + }, + { + "login": "JagandeepBrar", + "name": "Jagandeep Brar", + "avatar_url": "https://avatars.githubusercontent.com/u/3048295?v=4", + "profile": "https://www.jagandeepbrar.io", + "contributions": [ + "doc" + ] + }, + { + "login": "dtalens", + "name": "dtalens", + "avatar_url": "https://avatars.githubusercontent.com/u/6631832?v=4", + "profile": "http://dtalens.com", + "contributions": [ + "translation" + ] } ], "badgeTemplate": "\"All-orange.svg\"/>", diff --git a/.dockerignore b/.dockerignore index 4095dce5..ddb02133 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,16 +8,20 @@ .git .gitbook.yaml .gitconfig -.gitignore .github +.gitignore .next .prettierignore -config/db/db.sqlite3 -config/db/logs/overseerr.log +config/db/* +config/logs/* +config/*.json +dist Dockerfile* docker-compose.yml docs LICENSE node_modules +public/os_logo_square.png +public/preview.jpg snap stylelint.config.js diff --git a/.eslintrc.js b/.eslintrc.js index c7286440..b1c6f4b9 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,12 +4,10 @@ module.exports = { extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin - 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier - 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 'plugin:jsx-a11y/recommended', 'plugin:react/recommended', 'plugin:react-hooks/recommended', - 'prettier/react', + 'prettier', ], parserOptions: { ecmaVersion: 6, diff --git a/.gitignore b/.gitignore index 8e186622..0bc4be4a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,7 +32,7 @@ yarn-error.log* .vercel # database -config/db/*.sqlite3 +config/db/*.sqlite3* config/settings.json # logs diff --git a/.vscode/settings.json b/.vscode/settings.json index 459f6354..26aca34b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,5 +14,9 @@ "name": "Local SQLite", "database": "./config/db/db.sqlite3" } - ] + ], + "editor.codeActionsOnSave": { + "source.organizeImports": true + }, + "editor.formatOnSave": true } diff --git a/README.md b/README.md index 0fc140ed..07a836c6 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Language grade: JavaScript GitHub -All Contributors +All Contributors

@@ -148,8 +148,16 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
JonnyWong16

📖
Roxedus

📖
WoisWoi

🌍 -
HubDuck

🌍 +
HubDuck

🌍 📖
costaht

📖 🌍 +
Shjosan

🌍 +
kobaubarr

🌍 + + +
Ricardo González

🌍 +
Torkil

🌍 +
Jagandeep Brar

📖 +
dtalens

🌍 diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 0dc3de7c..0e84bdeb 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -8,8 +8,11 @@ ## Using Overseerr +- [Settings](using-overseerr/settings/README.md) - [Notifications](using-overseerr/notifications/README.md) - - [Custom Webhooks](using-overseerr/notifications/webhooks.md) + - [Email](using-overseerr/notifications/email.md) + - [Discord](using-overseerr/notifications/discord.md) + - [Webhooks](using-overseerr/notifications/webhooks.md) ## Support diff --git a/docs/support/faq.md b/docs/support/faq.md index 0fd6938f..b80b7751 100644 --- a/docs/support/faq.md +++ b/docs/support/faq.md @@ -90,6 +90,10 @@ You can also perform the following to verify the media item has a GUID Overseerr **A:** See "[Some media is missing from Overseerr that I know is in Plex!](./faq.md#some-media-is-missing-from-overseerr-that-i-know-is-in-plex)" for troubleshooting steps. +### Approved series requests keep failing! + +**A:** If you configured a base URL in Sonarr, make sure you have set the base URL option appropriately in Overseerr. Also, check that you are using Sonarr v3 and have configured a default language profile in Overseerr. + ## Notifications ### I am getting "Username and Password not accepted" when attempting to send email notifications via Gmail! diff --git a/docs/using-overseerr/notifications/README.md b/docs/using-overseerr/notifications/README.md index 3f13c0a9..05005233 100644 --- a/docs/using-overseerr/notifications/README.md +++ b/docs/using-overseerr/notifications/README.md @@ -2,10 +2,10 @@ Overseerr already supports a good number of notification agents, such as **Discord**, **Slack** and **Pushover**. New agents are always considered for development, if there is enough demand for it. -## Currently Supported Notification Agents +## Supported Notification Agents -- Discord -- Email +- [Email](./email.md) +- [Discord](./discord.md) - Pushbullet - Pushover - Slack @@ -14,15 +14,11 @@ Overseerr already supports a good number of notification agents, such as **Disco ## 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. +Configuring your notifications is quite 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. You must configure which type of notifications you want to send _per agent_. If no types are selected, you will not receive notifications! -Some agents may have specific configuration "gotchas" covered in their documentation pages. - -{% hint style="danger" %} -You will **not receive notifications** for any automatically approved requests unless the "Enable Notifications for Automatic Approvals" setting is enabled. -{% endhint %} +Note that some notifications are intended for the user who submitted the relevant request, while others are for administrators. For details, please see the documentation for the specific agent you would like to use. ## Requesting New Notification Agents diff --git a/docs/using-overseerr/notifications/discord.md b/docs/using-overseerr/notifications/discord.md new file mode 100644 index 00000000..a546ba38 --- /dev/null +++ b/docs/using-overseerr/notifications/discord.md @@ -0,0 +1,21 @@ +# Discord + +## Configuration + +{% hint style="info" %} +In order to configure Discord notifications, you first need to [create a webhook](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks). + +In order for users to be mentioned in Discord notifications, they must have their [Discord user ID](https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-) configured in their user settings. +{% endhint %} + +### Bot Username (optional) + +If you would like to override the name you configured for your bot in Discord, you may set this value to whatever you like! + +### Bot Avatar URL (optional) + +Similar to the bot username, you can override the avatar for your bot. + +### Webhook URL + +You can find the webhook URL in the Discord application, at **Server Settings → Integrations → Webhooks**. diff --git a/docs/using-overseerr/notifications/email.md b/docs/using-overseerr/notifications/email.md new file mode 100644 index 00000000..fd2c71c0 --- /dev/null +++ b/docs/using-overseerr/notifications/email.md @@ -0,0 +1,54 @@ +# Email + +{% hint style="info" %} +The following email notification types are sent to _all_ users with the **Manage Requests** permission, as these notification types are intended for application administrators rather than end users: + +- Media Requested +- Media Automatically Approved +- Media Failed + +On the other hand, the email notification types below are only sent to the user who submitted the request: + +- Media Approved +- Media Declined +- Media Available + +{% endhint %} + +## Configuration + +### Sender Address (required) + +Set this to the email address you would like to appear in the "from" field of the email message. + +Depending on your email provider, this may need to be an address you own. For example, Gmail requires this to be your actual email address. + +### Sender Name (optional) + +Configure a friendly name for the email sender. + +### SMTP Host + +Set this to the hostname or IP address of your SMTP host/server. + +### SMTP Port + +Set this to a supported port number for your SMTP host. `465` and `587` are commonly used. + +### Enable SSL (optional) + +This setting should only be enabled for ports that use [implicit SSL/TLS](https://tools.ietf.org/html/rfc8314) (e.g., port `465` in most cases). + +For servers that support [opportunistic TLS/STARTTLS](https://en.wikipedia.org/wiki/Opportunistic_TLS) (typically via port `587`), this setting should **not** be enabled. + +### SMTP Username & Password + +{% hint style="info" %} +If your account has two-factor authentication enabled, you may need to create an application password instead of using your account password. +{% endhint %} + +Configure these values as appropriate to authenticate with your SMTP host. + +### PGP Private Key & Password (optional) + +Configure these values to enable encrypting and signing of email messages using [OpenPGP](https://www.openpgp.org/). Note that individual users must also have their PGP public keys enabled in their user settings in order for PGP encryption to be used. diff --git a/docs/using-overseerr/notifications/webhooks.md b/docs/using-overseerr/notifications/webhooks.md index 68f54683..d1e91284 100644 --- a/docs/using-overseerr/notifications/webhooks.md +++ b/docs/using-overseerr/notifications/webhooks.md @@ -1,18 +1,20 @@ # Webhooks -Webhooks let you post a custom JSON payload to any endpoint you like. You can also set an authorization header for security purposes. +Webhooks allow you to send a custom JSON payload to any endpoint. You can also set an authorization header for security purposes. ## Configuration -The following configuration options are available: - ### Webhook URL (required) The URL you would like to post notifications to. Your JSON will be sent as the body of the request. -### Authorization Header +### Authorization Header (optional) -Custom authorization header. Anything entered for this will be sent as an `Authorization` header. +{% hint style="info" %} +This is typically not needed. Please refer to your webhook provider's documentation for details. +{% endhint %} + +This value will be sent as an `Authorization` HTTP header. ### JSON Payload (required) diff --git a/docs/using-overseerr/settings/README.md b/docs/using-overseerr/settings/README.md new file mode 100644 index 00000000..9b4c5a2e --- /dev/null +++ b/docs/using-overseerr/settings/README.md @@ -0,0 +1,195 @@ +# Settings + +## General + +### API Key + +This is your Overseerr API key, which can be used to integrate Overseerr with third-party applications. Do **not** share this key publicly, as it can be used to gain administrator access! + +If you need to generate a new API key for any reason, simply click the button to the right of the text box. + +### Application Title + +If you aren't a huge fan of the name "Overseerr" and would like to display something different to your users, you can customize the application title! + +### Application URL + +Set this to the externally-accessible URL of your Overseerr instance. If configured, [notifications](../notifications/README.md) will include links! + +### Enable Proxy Support + +If you have Overseerr behind a [reverse proxy](../../extending-overseerr/reverse-proxy-examples.md), enable this setting to allow Overseerr to correctly register client IP addresses. For details, please see the [Express documentation](http://expressjs.com/en/guide/behind-proxies.html). + +This setting is **disabled** by default. + +### Enable CSRF Protection + +{% hint style="danger" %} +**This is an advanced setting.** We do not recommend enabling it unless you understand the implications of doing so. +{% endhint %} + +CSRF stands for **Cross-Site Request Forgery**. When this setting is enabled, all external API access that alters Overseerr application data is blocked. + +If you do not use Overseerr integrations with third-party applications to add/modify/delete requests or users, you can consider enabling this setting to protect against malicious attacks. + +One caveat, however, is that **HTTPS is required**, meaning that once this setting is enabled, you will no longer be able to access your Overseerr instance over HTTP (including using an IP address and port number). + +If you enable this setting and find yourself unable to access Overseerr, you can disable the setting by modifying `settings.json` in `/app/config`. + +This setting is **disabled** by default. + +### Enable Image Caching + +{% hint style="danger" %} +**This feature is experimental.** Enable it at your own risk! +{% endhint %} + +When enabled, all images (including media posters from TMDb) will be cached locally on your server. Images will also be optimized for client devices; i.e., if you access Overseerr using a mobile device, smaller versions will be served compared to when accessing Overseerr on desktop. + +Note that this feature requires and will use a significant amount of disk space, and there is currently no automated deletion of old or expired images. If running Overseerr using Docker, it is possible to manually clear the image cache by simply removing and recreating the container. + +This setting is **disabled** by default. + +### Discover Region & Discover Language + +These settings filter content shown on the "Discover" home page based on regional availability and original language, respectively. Users can override these global settings by configuring these same options in their user settings. + +### Hide Available Media + +When enabled, media which is already available will not appear on the "Discover" home page, or in the "Recommended" or "Similar" categories or other links on media detail pages. + +Available media will still appear in search results, however, so it is possible to locate and view hidden items by searching for them by title. + +This setting is **disabled** by default. + +### Allow Partial Series Requests + +When enabled, users will be able to submit requests for specific seasons of TV series. If disabled, users will only be able to submit requests for all unavailable seasons. + +This setting is **enabled** by default. + +## Users + +### Enable Local User Sign-In + +When enabled, users who have configured passwords will be allowed to sign in using their email address. + +When disabled, Plex OAuth becomes the only sign-in option, and any "local users" you have created will not be able to sign in to Overseerr. + +This setting is **enabled** by default. + +### Default User Permissions + +Select the permissions you would like new users to have by default. It is important to set these, as any user with access to your Plex server will be able to log in to Overseerr, and they will be granted the permissions you select here. + +## Plex + +### Plex Settings + +{% hint style="info" %} +To set up Plex, you can either enter your details manually or select a server retrieved from [plex.tv](https://plex.tv/). Press the button to the right of the "Server" dropdown to retrieve available servers. + +Depending on your setup/configuration, you may need to enter your Plex server details manually in order to establish a connection from Overseerr. +{% endhint %} + +#### Server Name + +This value is automatically retrieved from Plex, and cannot be edited manually in Overseerr. + +#### Hostname or IP Address + +If you have Overseerr installed on the same network as Plex, you can set this to the local IP address of your Plex server. Otherwise, this should be set to a valid hostname (e.g., `plex.myawesomeserver.com`). + +#### Port + +This value should be set to the port that your Plex server listens on. The default port that Plex uses is `32400`, but you may need to set this to `443` or some other value if your Plex server is hosted on a VPS or cloud provider. + +#### SSL + +Tick this box to connect to Plex via HTTPS rather than HTTP. Note that self-signed certificates are **not** supported. + +### Plex Libraries + +In this section, simply select the libraries you would like Overseerr to scan. Overseerr will periodically check the selected libraries for available content to update the media status that is displayed to users. + +If you do not see your Plex libraries listed, verify your Plex settings and then click the "Scan Plex Libraries" button. + +### Manual Library Scan + +Overseerr will perform a full scan of your Plex libraries once every 24 hours (recently added items are fetched more frequently). If this is your first time configuring Plex, a one-time full manual library scan is recommended! + +## Services + +{% hint style="info" %} +If you keep separate copies of non-4K and 4K content in your media libraries, you will need to set up multiple Radarr/Sonarr instances and link each of them to Overseerr. + +Overseerr checks these linked servers to determine whether or not media has already been requested or is available, so two servers of each type are required _if you keep separate non-4K and 4K copies of media_. + +If you only maintain one copy of media, you can instead simply set up one server and set the "Quality Profile" setting on a per-request basis. +{% endhint %} + +### Radarr/Sonarr Settings + +#### Default Server + +At least one server needs to be marked as "Default" in order for requests to be sent successfully to Radarr/Sonarr. + +If you have separate 4K Radarr/Sonarr servers, you need to designate default 4K servers _in addition to_ default non-4K servers. + +#### 4K Server + +Only select this option if you have separate non-4K and 4K servers. If you only have a single Radarr/Sonarr server, do **not** check this box! + +#### Server Name + +Enter a friendly name for the Radarr/Sonarr server. + +#### Hostname or IP Address + +If you have Overseerr installed on the same network as Radarr/Sonarr, you can set this to the local IP address of your Radarr/Sonarr server. Otherwise, this should be set to a valid hostname (e.g., `radarr.myawesomeserver.com`). + +#### Port + +This value should be set to the port that your Radarr/Sonarr server listens on. By default, Radarr uses port `7878` and Sonarr uses port `8989`, but you may need to set this to `443` or some other value if your Radarr/Sonarr server is hosted on a VPS or cloud provider. + +#### SSL + +Tick this box to connect to Radarr/Sonarr via HTTPS rather than HTTP. Note that self-signed certificates are **not** supported. + +#### API Key + +Enter your Radarr/Sonarr API key here. Do **not** share these key publicly, as they can be used to gain administrator access to your Radarr/Sonarr servers! + +You can locate the required API keys in Radarr/Sonarr in **Settings → General → Security**. + +#### Base URL + +If you have configured a base URL for Radarr/Sonarr, you **must** enter it here in order for Overseerr to connect to those services! + +You can verify whether or not you have a base URL configured in Radarr/Sonarr in **Settings → General → Host**. (Note that a restart of your Radarr/Sonarr servers is required if you modify this setting!) + +#### Profiles, Root Folder, Minimum Availability + +Select the default settings you would like to use for all new requests. Note that all of these options are required, and that requests will fail if any of these are not configured! + +#### External URL + +If the hostname or IP address you configured above is not accessible outside your network, you can set a different URL here. This "external" URL is used to add clickable links to your Radarr/Sonarr servers on media detail pages. + +#### Enable Scan + +Tick this box if you would like to scan your Radarr/Sonarr server for existing media/request status. It is recommended that you enable this setting, so that users cannot submit requests for media which has already been requested or is already available. + +#### Disable Auto-Search + +If you do not want Radarr/Sonarr to automatically search for media upon submission of a request, you can disable this setting. + +## Notifications + +Please see [Notifications](../notifications/README.md) for details on configuring and enabling notifications. + +## Jobs & Cache + +Overseerr performs certain maintenance tasks as regularly-scheduled jobs, but they can also be manually triggered on this page. + +Overseerr also caches requests to external API endpoints to optimize performance and avoid making unnecessary API calls. If necessary, the cache for any particular endpoint can be cleared by clicking the "Flush Cache" button. diff --git a/next.config.js b/next.config.js index 8c1766af..a48ae97e 100644 --- a/next.config.js +++ b/next.config.js @@ -2,12 +2,16 @@ module.exports = { env: { commitTag: process.env.COMMIT_TAG || 'local', }, + images: { + domains: ['image.tmdb.org'], + }, + future: { + webpack5: true, + }, webpack(config) { config.module.rules.push({ test: /\.svg$/, - issuer: { - test: /\.(js|ts)x?$/, - }, + issuer: /\.(js|ts)x?$/, use: ['@svgr/webpack'], }); diff --git a/ormconfig.js b/ormconfig.js index 070e0598..4122f079 100644 --- a/ormconfig.js +++ b/ormconfig.js @@ -6,6 +6,7 @@ const devConfig = { synchronize: true, migrationsRun: false, logging: false, + enableWAL: true, entities: ['server/entity/**/*.ts'], migrations: ['server/migration/**/*.ts'], subscribers: ['server/subscriber/**/*.ts'], @@ -22,6 +23,7 @@ const prodConfig = { : 'config/db/db.sqlite3', synchronize: false, logging: false, + enableWAL: true, entities: ['dist/entity/**/*.js'], migrations: ['dist/migration/**/*.js'], migrationsRun: false, diff --git a/overseerr-api.yml b/overseerr-api.yml index b75b9b21..0fdfdcea 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -125,6 +125,9 @@ components: hideAvailable: type: boolean example: false + partialRequestsEnabled: + type: boolean + example: false localLogin: type: boolean example: true @@ -2249,6 +2252,54 @@ paths: responses: '204': description: 'Flushed cache' + /settings/logs: + get: + summary: Returns logs + description: Returns list of all log items and details + tags: + - settings + parameters: + - in: query + name: take + schema: + type: number + nullable: true + example: 25 + - in: query + name: skip + schema: + type: number + nullable: true + example: 0 + - in: query + name: filter + schema: + type: string + nullable: true + enum: [debug, info, warn, error] + default: debug + responses: + '200': + description: Server log returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + label: + type: string + example: server + level: + type: string + example: info + message: + type: string + example: Server ready on port 5055 + timestamp: + type: string + example: 2020-12-15T16:20:00.069Z /settings/notifications: get: summary: Return notification settings @@ -2451,8 +2502,8 @@ paths: $ref: '#/components/schemas/PushbulletSettings' /settings/notifications/pushbullet/test: post: - summary: Test Pushover settings - description: Sends a test notification to the Pushover agent. + summary: Test Pushbullet settings + description: Sends a test notification to the Pushbullet agent. tags: - settings requestBody: @@ -2460,7 +2511,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PushoverSettings' + $ref: '#/components/schemas/PushbulletSettings' responses: '204': description: Test notification attempted @@ -2598,7 +2649,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/SlackSettings' + $ref: '#/components/schemas/WebhookSettings' responses: '204': description: Test notification attempted @@ -2982,6 +3033,63 @@ paths: type: array items: $ref: '#/components/schemas/MediaRequest' + /user/{userId}/quota: + get: + summary: Get quotas for a specific user + description: | + Returns quota details for a user in a JSON object. Requires `MANAGE_USERS` permission if viewing other users. + tags: + - users + parameters: + - in: path + name: userId + required: true + schema: + type: number + responses: + '200': + description: User quota details in JSON + content: + application/json: + schema: + type: object + properties: + movie: + type: object + properties: + days: + type: number + example: 7 + limit: + type: number + example: 10 + used: + type: number + example: 6 + remaining: + type: number + example: 4 + restricted: + type: boolean + example: false + tv: + type: object + properties: + days: + type: number + example: 7 + limit: + type: number + example: 10 + used: + type: number + example: 6 + remaining: + type: number + example: 4 + restricted: + type: boolean + example: false /user/{userId}/settings/main: get: summary: Get general settings for a user @@ -3780,11 +3888,77 @@ paths: type: array items: $ref: '#/components/schemas/MovieResult' + /discover/genreslider/movie: + get: + summary: Get genre slider data for movies + description: Returns a list of genres with backdrops attached + tags: + - search + parameters: + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Genre slider data returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: number + example: 1 + backdrops: + type: array + items: + type: string + name: + type: string + example: Genre Name + /discover/genreslider/tv: + get: + summary: Get genre slider data for TV series + description: Returns a list of genres with backdrops attached + tags: + - search + parameters: + - in: query + name: language + schema: + type: string + example: en + responses: + '200': + description: Genre slider data returned + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: number + example: 1 + backdrops: + type: array + items: + type: string + name: + type: string + example: Genre Name /request: get: summary: Get all requests description: | Returns all requests if the user has the `ADMIN` or `MANAGE_REQUESTS` permissions. Otherwise, only the logged-in user's requests are returned. + + If the `requestedBy` parameter is specified, only requests from that particular user ID will be returned. tags: - request parameters: @@ -3812,6 +3986,12 @@ paths: type: string enum: [added, modified] default: added + - in: query + name: requestedBy + schema: + type: number + nullable: true + example: 1 responses: '200': description: Requests returned @@ -4421,7 +4601,7 @@ paths: type: number /media: get: - summary: Return media + summary: Get media description: Returns all media (can be filtered and limited) in a JSON object. tags: - media diff --git a/package.json b/package.json index 41fb0e02..21cd2481 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "yarn build:next && yarn build:server", "lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\"", "start": "NODE_ENV=production node dist/index.js", - "i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault false \"./src/**/!(*.test).{ts,tsx}\"", + "i18n:extract": "extract-messages -l=en -o src/i18n/locale -d en --flat true --overwriteDefault true \"./src/**/!(*.test).{ts,tsx}\"", "migration:generate": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:generate", "migration:create": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:create", "migration:run": "ts-node --project server/tsconfig.json ./node_modules/.bin/typeorm migration:run", @@ -20,7 +20,7 @@ "@headlessui/react": "^0.3.1", "@supercharge/request-ip": "^1.1.2", "@svgr/webpack": "^5.5.0", - "@tanem/react-nprogress": "^3.0.57", + "@tanem/react-nprogress": "^3.0.60", "ace-builds": "^1.4.12", "axios": "^0.21.1", "bcrypt": "^5.0.1", @@ -28,17 +28,19 @@ "bowser": "^2.11.0", "connect-typeorm": "^1.1.4", "cookie-parser": "^1.4.5", + "copy-to-clipboard": "^3.3.1", "country-flag-icons": "^1.2.9", "csurf": "^1.11.0", - "email-templates": "^8.0.3", + "email-templates": "^8.0.4", "express": "^4.17.1", - "express-openapi-validator": "^4.12.4", + "express-openapi-validator": "^4.12.6", + "express-rate-limit": "^5.2.6", "express-session": "^1.17.1", "formik": "^2.2.6", "gravatar-url": "^3.1.0", "intl": "^1.2.5", "lodash": "^4.17.21", - "next": "10.0.3", + "next": "10.1.2", "node-cache": "^5.1.2", "node-schedule": "^2.0.0", "nodemailer": "^6.5.0", @@ -46,13 +48,14 @@ "openpgp": "^5.0.0-1", "plex-api": "^5.3.1", "pug": "^3.0.2", - "react": "17.0.1", + "react": "17.0.2", "react-ace": "^9.3.0", "react-animate-height": "^2.0.23", - "react-dom": "17.0.1", + "react-dom": "17.0.2", "react-intersection-observer": "^8.31.0", - "react-intl": "^5.13.2", + "react-intl": "5.13.5", "react-markdown": "^5.0.3", + "react-select": "^4.3.0", "react-spring": "^8.0.27", "react-toast-notifications": "^2.4.3", "react-transition-group": "^4.4.1", @@ -62,8 +65,8 @@ "secure-random-password": "^0.2.2", "sqlite3": "^5.0.2", "swagger-ui-express": "^4.1.6", - "swr": "^0.5.1", - "typeorm": "^0.2.31", + "swr": "^0.5.5", + "typeorm": "^0.2.32", "uuid": "^8.3.2", "winston": "^3.3.3", "winston-daily-rotate-file": "^4.5.1", @@ -72,7 +75,7 @@ "yup": "^0.32.9" }, "devDependencies": { - "@babel/cli": "^7.13.10", + "@babel/cli": "^7.13.14", "@commitlint/cli": "^12.0.1", "@commitlint/config-conventional": "^12.0.1", "@semantic-release/changelog": "^5.0.1", @@ -80,7 +83,7 @@ "@semantic-release/exec": "^5.0.0", "@semantic-release/git": "^9.0.0", "@tailwindcss/aspect-ratio": "^0.2.0", - "@tailwindcss/forms": "^0.2.1", + "@tailwindcss/forms": "^0.3.2", "@tailwindcss/typography": "^0.4.0", "@types/bcrypt": "^3.0.0", "@types/body-parser": "^1.19.0", @@ -89,13 +92,15 @@ "@types/csurf": "^1.11.0", "@types/email-templates": "^8.0.2", "@types/express": "^4.17.11", + "@types/express-rate-limit": "^5.1.1", "@types/express-session": "^1.17.3", "@types/lodash": "^4.14.168", - "@types/node": "^14.14.34", + "@types/node": "^14.14.37", "@types/node-schedule": "^1.3.1", "@types/nodemailer": "^6.4.1", "@types/react": "^17.0.3", - "@types/react-dom": "^17.0.2", + "@types/react-dom": "^17.0.3", + "@types/react-select": "^4.0.13", "@types/react-toast-notifications": "^2.4.0", "@types/react-transition-group": "^4.4.1", "@types/secure-random-password": "^0.2.0", @@ -104,31 +109,30 @@ "@types/xml2js": "^0.4.8", "@types/yamljs": "^0.2.31", "@types/yup": "^0.29.11", - "@typescript-eslint/eslint-plugin": "^4.17.0", - "@typescript-eslint/parser": "^4.17.0", + "@typescript-eslint/eslint-plugin": "^4.20.0", + "@typescript-eslint/parser": "^4.20.0", "autoprefixer": "^10.2.5", "babel-plugin-react-intl": "^8.2.25", "babel-plugin-react-intl-auto": "^3.3.0", "commitizen": "^4.2.3", "copyfiles": "^2.4.1", "cz-conventional-changelog": "^3.3.0", - "eslint": "^7.21.0", - "eslint-config-prettier": "^7.2.0", - "eslint-plugin-formatjs": "^2.12.7", + "eslint": "^7.23.0", + "eslint-config-prettier": "^8.1.0", + "eslint-plugin-formatjs": "^2.14.3", "eslint-plugin-jsx-a11y": "^6.4.1", "eslint-plugin-prettier": "^3.3.1", - "eslint-plugin-react": "^7.22.0", + "eslint-plugin-react": "^7.23.1", "eslint-plugin-react-hooks": "^4.2.0", "extract-react-intl-messages": "^4.1.1", "husky": "4.3.8", "lint-staged": "^10.5.4", "nodemon": "^2.0.7", - "postcss": "^8.2.8", - "postcss-preset-env": "^6.7.0", + "postcss": "^8.2.9", "prettier": "^2.2.1", "semantic-release": "^17.4.2", "semantic-release-docker-buildx": "^1.0.1", - "tailwindcss": "npm:@tailwindcss/postcss7-compat", + "tailwindcss": "^2.0.4", "ts-node": "^9.1.1", "typescript": "^4.2.3" }, @@ -150,12 +154,10 @@ "lint-staged": { "**/*.{ts,tsx,js}": [ "prettier --write", - "eslint", - "git add" + "eslint" ], "**/*.{json,md}": [ - "prettier --write", - "git add" + "prettier --write" ] }, "commitlint": { diff --git a/postcss.config.js b/postcss.config.js index 7129bb14..12a703d9 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,3 +1,6 @@ module.exports = { - plugins: ['tailwindcss', 'postcss-preset-env'], + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, }; diff --git a/public/apple-splash-1125-2436.jpg b/public/apple-splash-1125-2436.jpg new file mode 100644 index 00000000..92d5e50f Binary files /dev/null and b/public/apple-splash-1125-2436.jpg differ diff --git a/public/apple-splash-1136-640.jpg b/public/apple-splash-1136-640.jpg new file mode 100644 index 00000000..c78884e4 Binary files /dev/null and b/public/apple-splash-1136-640.jpg differ diff --git a/public/apple-splash-1170-2532.jpg b/public/apple-splash-1170-2532.jpg new file mode 100644 index 00000000..a75e5e09 Binary files /dev/null and b/public/apple-splash-1170-2532.jpg differ diff --git a/public/apple-splash-1242-2208.jpg b/public/apple-splash-1242-2208.jpg new file mode 100644 index 00000000..8fa85721 Binary files /dev/null and b/public/apple-splash-1242-2208.jpg differ diff --git a/public/apple-splash-1242-2688.jpg b/public/apple-splash-1242-2688.jpg new file mode 100644 index 00000000..27b30024 Binary files /dev/null and b/public/apple-splash-1242-2688.jpg differ diff --git a/public/apple-splash-1284-2778.jpg b/public/apple-splash-1284-2778.jpg new file mode 100644 index 00000000..6c2f6993 Binary files /dev/null and b/public/apple-splash-1284-2778.jpg differ diff --git a/public/apple-splash-1334-750.jpg b/public/apple-splash-1334-750.jpg new file mode 100644 index 00000000..a0637f83 Binary files /dev/null and b/public/apple-splash-1334-750.jpg differ diff --git a/public/apple-splash-1536-2048.jpg b/public/apple-splash-1536-2048.jpg new file mode 100644 index 00000000..151a7a8e Binary files /dev/null and b/public/apple-splash-1536-2048.jpg differ diff --git a/public/apple-splash-1620-2160.jpg b/public/apple-splash-1620-2160.jpg new file mode 100644 index 00000000..39f2beec Binary files /dev/null and b/public/apple-splash-1620-2160.jpg differ diff --git a/public/apple-splash-1668-2224.jpg b/public/apple-splash-1668-2224.jpg new file mode 100644 index 00000000..1a2276da Binary files /dev/null and b/public/apple-splash-1668-2224.jpg differ diff --git a/public/apple-splash-1668-2388.jpg b/public/apple-splash-1668-2388.jpg new file mode 100644 index 00000000..6d936318 Binary files /dev/null and b/public/apple-splash-1668-2388.jpg differ diff --git a/public/apple-splash-1792-828.jpg b/public/apple-splash-1792-828.jpg new file mode 100644 index 00000000..82a1414f Binary files /dev/null and b/public/apple-splash-1792-828.jpg differ diff --git a/public/apple-splash-2048-1536.jpg b/public/apple-splash-2048-1536.jpg new file mode 100644 index 00000000..f6d31f0a Binary files /dev/null and b/public/apple-splash-2048-1536.jpg differ diff --git a/public/apple-splash-2048-2732.jpg b/public/apple-splash-2048-2732.jpg new file mode 100644 index 00000000..35205f1f Binary files /dev/null and b/public/apple-splash-2048-2732.jpg differ diff --git a/public/apple-splash-2160-1620.jpg b/public/apple-splash-2160-1620.jpg new file mode 100644 index 00000000..3d42ae04 Binary files /dev/null and b/public/apple-splash-2160-1620.jpg differ diff --git a/public/apple-splash-2208-1242.jpg b/public/apple-splash-2208-1242.jpg new file mode 100644 index 00000000..44502477 Binary files /dev/null and b/public/apple-splash-2208-1242.jpg differ diff --git a/public/apple-splash-2224-1668.jpg b/public/apple-splash-2224-1668.jpg new file mode 100644 index 00000000..46549038 Binary files /dev/null and b/public/apple-splash-2224-1668.jpg differ diff --git a/public/apple-splash-2388-1668.jpg b/public/apple-splash-2388-1668.jpg new file mode 100644 index 00000000..e08eb7c4 Binary files /dev/null and b/public/apple-splash-2388-1668.jpg differ diff --git a/public/apple-splash-2436-1125.jpg b/public/apple-splash-2436-1125.jpg new file mode 100644 index 00000000..64cb66a3 Binary files /dev/null and b/public/apple-splash-2436-1125.jpg differ diff --git a/public/apple-splash-2532-1170.jpg b/public/apple-splash-2532-1170.jpg new file mode 100644 index 00000000..f732a3ed Binary files /dev/null and b/public/apple-splash-2532-1170.jpg differ diff --git a/public/apple-splash-2688-1242.jpg b/public/apple-splash-2688-1242.jpg new file mode 100644 index 00000000..87889500 Binary files /dev/null and b/public/apple-splash-2688-1242.jpg differ diff --git a/public/apple-splash-2732-2048.jpg b/public/apple-splash-2732-2048.jpg new file mode 100644 index 00000000..8d432828 Binary files /dev/null and b/public/apple-splash-2732-2048.jpg differ diff --git a/public/apple-splash-2778-1284.jpg b/public/apple-splash-2778-1284.jpg new file mode 100644 index 00000000..a5a74684 Binary files /dev/null and b/public/apple-splash-2778-1284.jpg differ diff --git a/public/apple-splash-640-1136.jpg b/public/apple-splash-640-1136.jpg new file mode 100644 index 00000000..9a64bba6 Binary files /dev/null and b/public/apple-splash-640-1136.jpg differ diff --git a/public/apple-splash-750-1334.jpg b/public/apple-splash-750-1334.jpg new file mode 100644 index 00000000..a6942629 Binary files /dev/null and b/public/apple-splash-750-1334.jpg differ diff --git a/public/apple-splash-828-1792.jpg b/public/apple-splash-828-1792.jpg new file mode 100644 index 00000000..95f97c08 Binary files /dev/null and b/public/apple-splash-828-1792.jpg differ diff --git a/public/images/radarr_logo.svg b/public/images/radarr_logo.svg index 4af99613..231b9f93 100644 --- a/public/images/radarr_logo.svg +++ b/public/images/radarr_logo.svg @@ -1 +1 @@ -image/svg+xml + \ No newline at end of file diff --git a/public/images/sonarr_logo.svg b/public/images/sonarr_logo.svg index f45e9927..2175728d 100644 --- a/public/images/sonarr_logo.svg +++ b/public/images/sonarr_logo.svg @@ -1 +1 @@ - + \ No newline at end of file diff --git a/public/site.webmanifest b/public/site.webmanifest index 45d6efa4..6cd90611 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -6,12 +6,14 @@ { "src": "/android-chrome-192x192.png", "sizes": "192x192", - "type": "image/png" + "type": "image/png", + "purpose": "maskable" }, { "src": "/android-chrome-512x512.png", "sizes": "512x512", - "type": "image/png" + "type": "image/png", + "purpose": "maskable" } ], "theme_color": "#2d3748", diff --git a/server/api/plexapi.ts b/server/api/plexapi.ts index f920a5b3..b87dc342 100644 --- a/server/api/plexapi.ts +++ b/server/api/plexapi.ts @@ -118,7 +118,7 @@ class PlexAPI { options: { identifier: settings.clientId, product: 'Overseerr', - deviceName: settings.main.applicationTitle, + deviceName: 'Overseerr', platform: 'Overseerr', }, }); diff --git a/server/api/themoviedb/index.ts b/server/api/themoviedb/index.ts index e98ebb7e..c52e971b 100644 --- a/server/api/themoviedb/index.ts +++ b/server/api/themoviedb/index.ts @@ -11,6 +11,7 @@ import { TmdbNetwork, TmdbPersonCombinedCredits, TmdbPersonDetail, + TmdbProductionCompany, TmdbRegion, TmdbSearchMovieResponse, TmdbSearchMultiResponse, @@ -18,7 +19,6 @@ import { TmdbSeasonWithEpisodes, TmdbTvDetails, TmdbUpcomingMoviesResponse, - TmdbProductionCompany, } from './interfaces'; interface SearchOptions { @@ -614,9 +614,7 @@ class TheMovieDb extends ExternalAPI { return tvshow; } - throw new Error( - `[TMDb] Failed to find a TV show with the provided TVDB ID: ${tvdbId}` - ); + throw new Error(`No show returned from API for ID ${tvdbId}`); } catch (e) { throw new Error( `[TMDb] Failed to get TV show using the external TVDB ID: ${e.message}` diff --git a/server/api/themoviedb/interfaces.ts b/server/api/themoviedb/interfaces.ts index f626a16d..c3b02b6c 100644 --- a/server/api/themoviedb/interfaces.ts +++ b/server/api/themoviedb/interfaces.ts @@ -306,6 +306,7 @@ export interface TmdbKeyword { export interface TmdbPersonDetail { id: number; name: string; + birthday: string; deathday: string; known_for_department: string; also_known_as?: string[]; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 78d8ee96..6e5c1135 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -10,6 +10,7 @@ import { getRepository, OneToMany, AfterRemove, + RelationCount, } from 'typeorm'; import { User } from './User'; import Media from './Media'; @@ -60,6 +61,9 @@ export class MediaRequest { @Column({ type: 'varchar' }) public type: MediaType; + @RelationCount((request: MediaRequest) => request.seasons) + public seasonCount: number; + @OneToMany(() => SeasonRequest, (season) => season.request, { eager: true, cascade: true, @@ -179,7 +183,7 @@ export class MediaRequest { subject: movie.title, message: movie.overview, image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, - notifyUser: this.requestedBy, + notifyUser: autoApproved ? undefined : this.requestedBy, media, request: this, } @@ -444,15 +448,10 @@ export class MediaRequest { 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, + message: movie.overview, media, image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, request: this, @@ -641,14 +640,10 @@ export class MediaRequest { 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, + message: series.overview, image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${series.poster_path}`, media, extra: [ diff --git a/server/entity/User.ts b/server/entity/User.ts index 50ede81d..db5fa950 100644 --- a/server/entity/User.ts +++ b/server/entity/User.ts @@ -1,28 +1,34 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, - OneToMany, - RelationCount, - AfterLoad, - OneToOne, -} from 'typeorm'; -import { - Permission, - hasPermission, - PermissionCheckOptions, -} from '../lib/permissions'; -import { MediaRequest } from './MediaRequest'; import bcrypt from 'bcrypt'; import path from 'path'; -import PreparedEmail from '../lib/email'; -import logger from '../logger'; -import { getSettings } from '../lib/settings'; import { default as generatePassword } from 'secure-random-password'; -import { UserType } from '../constants/user'; +import { + AfterLoad, + Column, + CreateDateColumn, + Entity, + getRepository, + MoreThan, + Not, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, + RelationCount, + UpdateDateColumn, +} from 'typeorm'; import { v4 as uuid } from 'uuid'; +import { MediaRequestStatus, MediaType } from '../constants/media'; +import { UserType } from '../constants/user'; +import { QuotaResponse } from '../interfaces/api/userInterfaces'; +import PreparedEmail from '../lib/email'; +import { + hasPermission, + Permission, + PermissionCheckOptions, +} from '../lib/permissions'; +import { getSettings } from '../lib/settings'; +import logger from '../logger'; +import { MediaRequest } from './MediaRequest'; +import SeasonRequest from './SeasonRequest'; import { UserSettings } from './UserSettings'; @Entity() @@ -80,6 +86,18 @@ export class User { @OneToMany(() => MediaRequest, (request) => request.requestedBy) public requests: MediaRequest[]; + @Column({ nullable: true }) + public movieQuotaLimit?: number; + + @Column({ nullable: true }) + public movieQuotaDays?: number; + + @Column({ nullable: true }) + public tvQuotaLimit?: number; + + @Column({ nullable: true }) + public tvQuotaDays?: number; + @OneToOne(() => UserSettings, (settings) => settings.user, { cascade: true, eager: true, @@ -199,4 +217,105 @@ export class User { public setDisplayName(): void { this.displayName = this.username || this.plexUsername; } + + public async getQuota(): Promise { + const { + main: { defaultQuotas }, + } = getSettings(); + const requestRepository = getRepository(MediaRequest); + const canBypass = this.hasPermission([Permission.MANAGE_USERS], { + type: 'or', + }); + + const movieQuotaLimit = !canBypass + ? this.movieQuotaLimit ?? defaultQuotas.movie.quotaLimit + : 0; + const movieQuotaDays = this.movieQuotaDays ?? defaultQuotas.movie.quotaDays; + + // Count movie requests made during quota period + const movieDate = new Date(); + if (movieQuotaDays) { + movieDate.setDate(movieDate.getDate() - movieQuotaDays); + } else { + movieDate.setDate(0); + } + // YYYY-MM-DD format + const movieQuotaStartDate = movieDate.toJSON().split('T')[0]; + const movieQuotaUsed = movieQuotaLimit + ? await requestRepository.count({ + where: { + requestedBy: this, + createdAt: MoreThan(movieQuotaStartDate), + type: MediaType.MOVIE, + status: Not(MediaRequestStatus.DECLINED), + }, + }) + : 0; + + const tvQuotaLimit = !canBypass + ? this.tvQuotaLimit ?? defaultQuotas.tv.quotaLimit + : 0; + const tvQuotaDays = this.tvQuotaDays ?? defaultQuotas.tv.quotaDays; + + // Count tv season requests made during quota period + const tvDate = new Date(); + if (tvQuotaDays) { + tvDate.setDate(tvDate.getDate() - tvQuotaDays); + } else { + tvDate.setDate(0); + } + // YYYY-MM-DD format + const tvQuotaStartDate = tvDate.toJSON().split('T')[0]; + const tvQuotaUsed = tvQuotaLimit + ? ( + await requestRepository + .createQueryBuilder('request') + .leftJoin('request.seasons', 'seasons') + .leftJoin('request.requestedBy', 'requestedBy') + .where('request.type = :requestType', { + requestType: MediaType.TV, + }) + .andWhere('requestedBy.id = :userId', { + userId: this.id, + }) + .andWhere('request.createdAt > :date', { + date: tvQuotaStartDate, + }) + .andWhere('request.status != :declinedStatus', { + declinedStatus: MediaRequestStatus.DECLINED, + }) + .addSelect((subQuery) => { + return subQuery + .select('COUNT(season.id)', 'seasonCount') + .from(SeasonRequest, 'season') + .leftJoin('season.request', 'parentRequest') + .where('parentRequest.id = request.id'); + }, 'seasonCount') + .getMany() + ).reduce((sum: number, req: MediaRequest) => sum + req.seasonCount, 0) + : 0; + + return { + movie: { + days: movieQuotaDays, + limit: movieQuotaLimit, + used: movieQuotaUsed, + remaining: movieQuotaLimit + ? movieQuotaLimit - movieQuotaUsed + : undefined, + restricted: + movieQuotaLimit && movieQuotaLimit - movieQuotaUsed <= 0 + ? true + : false, + }, + tv: { + days: tvQuotaDays, + limit: tvQuotaLimit, + used: tvQuotaUsed, + remaining: tvQuotaLimit ? tvQuotaLimit - tvQuotaUsed : undefined, + restricted: + tvQuotaLimit && tvQuotaLimit - tvQuotaUsed <= 0 ? true : false, + }, + }; + } } diff --git a/server/interfaces/api/discoverInterfaces.ts b/server/interfaces/api/discoverInterfaces.ts new file mode 100644 index 00000000..db90e55d --- /dev/null +++ b/server/interfaces/api/discoverInterfaces.ts @@ -0,0 +1,5 @@ +export interface GenreSliderItem { + id: number; + name: string; + backdrops: string[]; +} diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 122df7bd..72ac9b8a 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -1,3 +1,17 @@ +import type { PaginatedResponse } from './common'; + +export type LogMessage = { + timestamp: string; + level: string; + label: string; + message: string; + data?: Record; +}; + +export interface LogsResultsResponse extends PaginatedResponse { + results: LogMessage[]; +} + export interface SettingsAboutResponse { version: string; totalRequests: number; @@ -14,6 +28,8 @@ export interface PublicSettingsResponse { series4kEnabled: boolean; region: string; originalLanguage: string; + partialRequestsEnabled: boolean; + cacheImages: boolean; } export interface CacheItem { diff --git a/server/interfaces/api/userInterfaces.ts b/server/interfaces/api/userInterfaces.ts index 259455dc..facacd54 100644 --- a/server/interfaces/api/userInterfaces.ts +++ b/server/interfaces/api/userInterfaces.ts @@ -1,5 +1,5 @@ -import type { User } from '../../entity/User'; import { MediaRequest } from '../../entity/MediaRequest'; +import type { User } from '../../entity/User'; import { PaginatedResponse } from './common'; export interface UserResultsResponse extends PaginatedResponse { @@ -9,3 +9,16 @@ export interface UserResultsResponse extends PaginatedResponse { export interface UserRequestsResponse extends PaginatedResponse { results: MediaRequest[]; } + +export interface QuotaStatus { + days?: number; + limit?: number; + used: number; + remaining?: number; + restricted: boolean; +} + +export interface QuotaResponse { + movie: QuotaStatus; + tv: QuotaStatus; +} diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 91653991..e6d0302f 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -2,6 +2,14 @@ export interface UserSettingsGeneralResponse { username?: string; region?: string; originalLanguage?: string; + movieQuotaLimit?: number; + movieQuotaDays?: number; + tvQuotaLimit?: number; + tvQuotaDays?: number; + globalMovieQuotaDays?: number; + globalMovieQuotaLimit?: number; + globalTvQuotaLimit?: number; + globalTvQuotaDays?: number; } export interface UserSettingsNotificationsResponse { diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts index 51791322..132683e5 100644 --- a/server/lib/notifications/agents/agent.ts +++ b/server/lib/notifications/agents/agent.ts @@ -6,7 +6,7 @@ import { NotificationAgentConfig } from '../../settings'; export interface NotificationPayload { subject: string; - notifyUser: User; + notifyUser?: User; media?: Media; image?: string; message?: string; @@ -21,15 +21,9 @@ export abstract class BaseAgent { } protected abstract getSettings(): T; - - protected userNotificationTypes: Notification[] = [ - Notification.MEDIA_APPROVED, - Notification.MEDIA_DECLINED, - Notification.MEDIA_AVAILABLE, - ]; } export interface NotificationAgent { - shouldSend(type: Notification, payload: NotificationPayload): boolean; + shouldSend(type: Notification): boolean; send(type: Notification, payload: NotificationPayload): Promise; } diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index 5c02240e..cefde186 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -107,7 +107,7 @@ class DiscordAgent if (payload.request) { fields.push({ name: 'Requested By', - value: payload.notifyUser.displayName ?? '', + value: payload.request?.requestedBy.displayName ?? '', inline: true, }); } @@ -217,8 +217,8 @@ class DiscordAgent let content = undefined; if ( - this.userNotificationTypes.includes(type) && - payload.notifyUser.settings?.enableNotifications && + payload.notifyUser && + (payload.notifyUser.settings?.enableNotifications ?? true) && payload.notifyUser.settings?.discordId ) { mentionedUsers.push(payload.notifyUser.settings.discordId); diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index 64483c13..ea6b02ef 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -1,13 +1,13 @@ -import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; -import { hasNotificationType, Notification } from '..'; import path from 'path'; -import { getSettings, NotificationAgentEmail } from '../../settings'; -import logger from '../../../logger'; import { getRepository } from 'typeorm'; -import { User } from '../../../entity/User'; -import { Permission } from '../../permissions'; -import PreparedEmail from '../../email'; +import { hasNotificationType, Notification } from '..'; import { MediaType } from '../../../constants/media'; +import { User } from '../../../entity/User'; +import logger from '../../../logger'; +import PreparedEmail from '../../email'; +import { Permission } from '../../permissions'; +import { getSettings, NotificationAgentEmail } from '../../settings'; +import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; class EmailAgent extends BaseAgent @@ -22,13 +22,12 @@ class EmailAgent return settings.notifications.agents.email; } - public shouldSend(type: Notification, payload: NotificationPayload): boolean { + public shouldSend(type: Notification): boolean { const settings = this.getSettings(); if ( settings.enabled && - hasNotificationType(type, this.getSettings().types) && - (payload.notifyUser.settings?.enableNotifications ?? true) + hasNotificationType(type, this.getSettings().types) ) { return true; } @@ -45,9 +44,13 @@ class EmailAgent // Send to all users with the manage requests permission (or admins) users - .filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS)) + .filter( + (user) => + user.hasPermission(Permission.MANAGE_REQUESTS) && + (user.settings?.enableNotifications ?? true) + ) .forEach((user) => { - const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); + const email = new PreparedEmail(user.settings?.pgpKey); email.send({ template: path.join( @@ -62,9 +65,11 @@ class EmailAgent payload.media?.mediaType === MediaType.TV ? 'series' : 'movie' }!`, mediaName: payload.subject, + mediaPlot: payload.message, + mediaExtra: payload.extra ?? [], imageUrl: payload.image, timestamp: new Date().toTimeString(), - requestedBy: payload.notifyUser.displayName, + requestedBy: payload.request?.requestedBy.displayName, actionUrl: applicationUrl ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` : undefined, @@ -95,9 +100,13 @@ class EmailAgent // Send to all users with the manage requests permission (or admins) users - .filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS)) + .filter( + (user) => + user.hasPermission(Permission.MANAGE_REQUESTS) && + (user.settings?.enableNotifications ?? true) + ) .forEach((user) => { - const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); + const email = new PreparedEmail(user.settings?.pgpKey); email.send({ template: path.join( @@ -112,11 +121,12 @@ class EmailAgent payload.media?.mediaType === MediaType.TV ? 'series' : 'movie' } could not be added to ${ payload.media?.mediaType === MediaType.TV ? 'Sonarr' : 'Radarr' - }`, + }:`, mediaName: payload.subject, + mediaPlot: payload.message, imageUrl: payload.image, timestamp: new Date().toTimeString(), - requestedBy: payload.notifyUser.displayName, + requestedBy: payload.request?.requestedBy.displayName, actionUrl: applicationUrl ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` : undefined, @@ -142,34 +152,41 @@ class EmailAgent // This is getting main settings for the whole app const { applicationUrl, applicationTitle } = getSettings().main; try { - const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); + if ( + payload.notifyUser && + (payload.notifyUser.settings?.enableNotifications ?? true) + ) { + const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); + + await email.send({ + template: path.join( + __dirname, + '../../../templates/email/media-request' + ), + message: { + to: payload.notifyUser.email, + }, + locals: { + body: `Your request for the following ${ + payload.media?.mediaType === MediaType.TV ? 'series' : 'movie' + } has been approved:`, + mediaName: payload.subject, + mediaExtra: payload.extra ?? [], + imageUrl: payload.image, + timestamp: new Date().toTimeString(), + requestedBy: payload.request?.requestedBy.displayName, + actionUrl: applicationUrl + ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` + : undefined, + applicationUrl, + applicationTitle, + requestType: `${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request Approved`, + }, + }); + } - await email.send({ - template: path.join( - __dirname, - '../../../templates/email/media-request' - ), - message: { - to: payload.notifyUser.email, - }, - locals: { - body: `Your request for the following ${ - payload.media?.mediaType === MediaType.TV ? 'series' : 'movie' - } has been approved:`, - mediaName: payload.subject, - imageUrl: payload.image, - timestamp: new Date().toTimeString(), - requestedBy: payload.notifyUser.displayName, - actionUrl: applicationUrl - ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` - : undefined, - applicationUrl, - applicationTitle, - requestType: `${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request Approved`, - }, - }); return true; } catch (e) { logger.error('Email notification failed to send', { @@ -189,7 +206,11 @@ class EmailAgent // Send to all users with the manage requests permission (or admins) users - .filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS)) + .filter( + (user) => + user.hasPermission(Permission.MANAGE_REQUESTS) && + (user.settings?.enableNotifications ?? true) + ) .forEach((user) => { const email = new PreparedEmail(); @@ -206,9 +227,10 @@ class EmailAgent payload.media?.mediaType === MediaType.TV ? 'series' : 'movie' } has been automatically approved:`, mediaName: payload.subject, + mediaExtra: payload.extra ?? [], imageUrl: payload.image, timestamp: new Date().toTimeString(), - requestedBy: payload.notifyUser.displayName, + requestedBy: payload.request?.requestedBy.displayName, actionUrl: applicationUrl ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` : undefined, @@ -234,34 +256,41 @@ class EmailAgent // This is getting main settings for the whole app const { applicationUrl, applicationTitle } = getSettings().main; try { - const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); + if ( + payload.notifyUser && + (payload.notifyUser.settings?.enableNotifications ?? true) + ) { + const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); + + await email.send({ + template: path.join( + __dirname, + '../../../templates/email/media-request' + ), + message: { + to: payload.notifyUser.email, + }, + locals: { + body: `Your request for the following ${ + payload.media?.mediaType === MediaType.TV ? 'series' : 'movie' + } was declined:`, + mediaName: payload.subject, + mediaExtra: payload.extra ?? [], + imageUrl: payload.image, + timestamp: new Date().toTimeString(), + requestedBy: payload.request?.requestedBy.displayName, + actionUrl: applicationUrl + ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` + : undefined, + applicationUrl, + applicationTitle, + requestType: `${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Request Declined`, + }, + }); + } - await email.send({ - template: path.join( - __dirname, - '../../../templates/email/media-request' - ), - message: { - to: payload.notifyUser.email, - }, - locals: { - body: `Your request for the following ${ - payload.media?.mediaType === MediaType.TV ? 'series' : 'movie' - } was declined:`, - mediaName: payload.subject, - imageUrl: payload.image, - timestamp: new Date().toTimeString(), - requestedBy: payload.notifyUser.displayName, - actionUrl: applicationUrl - ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` - : undefined, - applicationUrl, - applicationTitle, - requestType: `${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Request Declined`, - }, - }); return true; } catch (e) { logger.error('Email notification failed to send', { @@ -276,34 +305,41 @@ class EmailAgent // This is getting main settings for the whole app const { applicationUrl, applicationTitle } = getSettings().main; try { - const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); + if ( + payload.notifyUser && + (payload.notifyUser.settings?.enableNotifications ?? true) + ) { + const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); + + await email.send({ + template: path.join( + __dirname, + '../../../templates/email/media-request' + ), + message: { + to: payload.notifyUser.email, + }, + locals: { + body: `The following ${ + payload.media?.mediaType === MediaType.TV ? 'series' : 'movie' + } you requested is now available!`, + mediaName: payload.subject, + mediaExtra: payload.extra ?? [], + imageUrl: payload.image, + timestamp: new Date().toTimeString(), + requestedBy: payload.request?.requestedBy.displayName, + actionUrl: applicationUrl + ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` + : undefined, + applicationUrl, + applicationTitle, + requestType: `${ + payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' + } Now Available`, + }, + }); + } - await email.send({ - template: path.join( - __dirname, - '../../../templates/email/media-request' - ), - message: { - to: payload.notifyUser.email, - }, - locals: { - body: `The following ${ - payload.media?.mediaType === MediaType.TV ? 'series' : 'movie' - } you requested is now available!`, - mediaName: payload.subject, - imageUrl: payload.image, - timestamp: new Date().toTimeString(), - requestedBy: payload.notifyUser.displayName, - actionUrl: applicationUrl - ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` - : undefined, - applicationUrl, - applicationTitle, - requestType: `${ - payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' - } Now Available`, - }, - }); return true; } catch (e) { logger.error('Email notification failed to send', { @@ -318,19 +354,22 @@ class EmailAgent // This is getting main settings for the whole app const { applicationUrl, applicationTitle } = getSettings().main; try { - const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); + if (payload.notifyUser) { + const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey); + + await email.send({ + template: path.join(__dirname, '../../../templates/email/test-email'), + message: { + to: payload.notifyUser.email, + }, + locals: { + body: payload.message, + applicationUrl, + applicationTitle, + }, + }); + } - await email.send({ - template: path.join(__dirname, '../../../templates/email/test-email'), - message: { - to: payload.notifyUser.email, - }, - locals: { - body: payload.message, - applicationUrl, - applicationTitle, - }, - }); return true; } catch (e) { logger.error('Email notification failed to send', { diff --git a/server/lib/notifications/agents/pushbullet.ts b/server/lib/notifications/agents/pushbullet.ts index ef40bff3..f0c0f757 100644 --- a/server/lib/notifications/agents/pushbullet.ts +++ b/server/lib/notifications/agents/pushbullet.ts @@ -47,7 +47,7 @@ class PushbulletAgent const title = payload.subject; const plot = payload.message; - const username = payload.notifyUser.displayName; + const username = payload.request?.requestedBy.displayName; switch (type) { case Notification.MEDIA_PENDING: @@ -122,6 +122,10 @@ class PushbulletAgent break; } + for (const extra of payload.extra ?? []) { + message += `\n${extra.name}: ${extra.value}`; + } + return { title: messageTitle, body: message, diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts index 588b46c7..3b5d3f87 100644 --- a/server/lib/notifications/agents/pushover.ts +++ b/server/lib/notifications/agents/pushover.ts @@ -61,7 +61,7 @@ class PushoverAgent const title = payload.subject; const plot = payload.message; - const username = payload.notifyUser.displayName; + const username = payload.request?.requestedBy.displayName; switch (type) { case Notification.MEDIA_PENDING: @@ -70,10 +70,10 @@ class PushoverAgent } Request`; message += `${title}`; if (plot) { - message += `\n${plot}`; + message += `\n${plot}`; } - message += `\n\nRequested By\n${username}`; - message += `\n\nStatus\nPending Approval`; + message += `\n\nRequested By\n${username}`; + message += `\n\nStatus\nPending Approval`; break; case Notification.MEDIA_APPROVED: messageTitle = `${ @@ -81,10 +81,10 @@ class PushoverAgent } Request Approved`; message += `${title}`; if (plot) { - message += `\n${plot}`; + message += `\n${plot}`; } - message += `\n\nRequested By\n${username}`; - message += `\n\nStatus\nProcessing`; + message += `\n\nRequested By\n${username}`; + message += `\n\nStatus\nProcessing`; break; case Notification.MEDIA_AUTO_APPROVED: messageTitle = `${ @@ -92,10 +92,10 @@ class PushoverAgent } Request Automatically Approved`; message += `${title}`; if (plot) { - message += `\n${plot}`; + message += `\n${plot}`; } - message += `\n\nRequested By\n${username}`; - message += `\n\nStatus\nProcessing`; + message += `\n\nRequested By\n${username}`; + message += `\n\nStatus\nProcessing`; break; case Notification.MEDIA_AVAILABLE: messageTitle = `${ @@ -103,10 +103,10 @@ class PushoverAgent } Now Available`; message += `${title}`; if (plot) { - message += `\n${plot}`; + message += `\n${plot}`; } - message += `\n\nRequested By\n${username}`; - message += `\n\nStatus\nAvailable`; + message += `\n\nRequested By\n${username}`; + message += `\n\nStatus\nAvailable`; break; case Notification.MEDIA_DECLINED: messageTitle = `${ @@ -114,10 +114,10 @@ class PushoverAgent } Request Declined`; message += `${title}`; if (plot) { - message += `\n${plot}`; + message += `\n${plot}`; } - message += `\n\nRequested By\n${username}`; - message += `\n\nStatus\nDeclined`; + message += `\n\nRequested By\n${username}`; + message += `\n\nStatus\nDeclined`; priority = 1; break; case Notification.MEDIA_FAILED: @@ -126,18 +126,22 @@ class PushoverAgent } Request`; message += `${title}`; if (plot) { - message += `\n${plot}`; + message += `\n${plot}`; } - message += `\n\nRequested By\n${username}`; - message += `\n\nStatus\nFailed`; + message += `\n\nRequested By\n${username}`; + message += `\n\nStatus\nFailed`; priority = 1; break; case Notification.TEST_NOTIFICATION: messageTitle = 'Test Notification'; - message += `${plot}`; + message += `${plot}`; break; } + for (const extra of payload.extra ?? []) { + message += `\n\n${extra.name}\n${extra.value}`; + } + if (settings.main.applicationUrl && payload.media) { url = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`; url_title = `Open in ${settings.main.applicationTitle}`; diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts index fc6643d6..b5234785 100644 --- a/server/lib/notifications/agents/slack.ts +++ b/server/lib/notifications/agents/slack.ts @@ -67,7 +67,9 @@ class SlackAgent if (payload.request) { fields.push({ type: 'mrkdwn', - text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`, + text: `*Requested By*\n${ + payload.request?.requestedBy.displayName ?? '' + }`, }); } @@ -131,6 +133,13 @@ class SlackAgent break; } + for (const extra of payload.extra ?? []) { + fields.push({ + type: 'mrkdwn', + text: `*${extra.name}*\n${extra.value}`, + }); + } + if (settings.main.applicationUrl && payload.media) { actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`; } diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index f26c5cbb..5fa4c518 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -1,8 +1,8 @@ import axios from 'axios'; import { hasNotificationType, Notification } from '..'; +import { MediaType } from '../../../constants/media'; import logger from '../../../logger'; import { getSettings, NotificationAgentTelegram } from '../../settings'; -import { MediaType } from '../../../constants/media'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; interface TelegramMessagePayload { @@ -61,7 +61,7 @@ class TelegramAgent const title = this.escapeText(payload.subject); const plot = this.escapeText(payload.message); - const user = this.escapeText(payload.notifyUser.displayName); + const user = this.escapeText(payload.request?.requestedBy.displayName); const applicationTitle = this.escapeText(settings.main.applicationTitle); /* eslint-disable no-useless-escape */ @@ -138,6 +138,10 @@ class TelegramAgent break; } + for (const extra of payload.extra ?? []) { + message += `\n\n\*${extra.name}\*\n${extra.value}`; + } + if (settings.main.applicationUrl && payload.media) { const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`; message += `\n\n\[Open in ${applicationTitle}\]\(${actionUrl}\)`; @@ -175,8 +179,8 @@ class TelegramAgent // Send user notification if ( - this.userNotificationTypes.includes(type) && - payload.notifyUser.settings?.enableNotifications && + payload.notifyUser && + (payload.notifyUser.settings?.enableNotifications ?? true) && payload.notifyUser.settings?.telegramChatId && payload.notifyUser.settings?.telegramChatId !== this.getSettings().options.chatId diff --git a/server/lib/notifications/index.ts b/server/lib/notifications/index.ts index b9cc84a9..7d5b6800 100644 --- a/server/lib/notifications/index.ts +++ b/server/lib/notifications/index.ts @@ -50,7 +50,7 @@ class NotificationManager { label: 'Notifications', }); this.activeAgents.forEach((agent) => { - if (settings.enabled && agent.shouldSend(type, payload)) { + if (settings.enabled && agent.shouldSend(type)) { agent.send(type, payload); } }); diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index d845a352..b7e36547 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -165,7 +165,7 @@ class BaseScanner { if (changedExisting) { await mediaRepository.save(existing); this.log( - `Media for ${title} exists. Changed were detected and the title will be updated.`, + `Media for ${title} exists. Changes were detected and the title will be updated.`, 'info' ); } else { diff --git a/server/lib/settings.ts b/server/lib/settings.ts index ad0c3dba..5809600f 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -1,6 +1,6 @@ import fs from 'fs'; -import path from 'path'; import { merge } from 'lodash'; +import path from 'path'; import { v4 as uuidv4 } from 'uuid'; import { Permission } from './permissions'; @@ -61,17 +61,28 @@ export interface SonarrSettings extends DVRSettings { enableSeasonFolders: boolean; } +interface Quota { + quotaLimit?: number; + quotaDays?: number; +} + export interface MainSettings { apiKey: string; applicationTitle: string; applicationUrl: string; csrfProtection: boolean; + cacheImages: boolean; defaultPermissions: number; + defaultQuotas: { + movie: Quota; + tv: Quota; + }; hideAvailable: boolean; localLogin: boolean; region: string; originalLanguage: string; trustProxy: boolean; + partialRequestsEnabled: boolean; } interface PublicSettings { @@ -86,6 +97,8 @@ interface FullPublicSettings extends PublicSettings { series4kEnabled: boolean; region: string; originalLanguage: string; + partialRequestsEnabled: boolean; + cacheImages: boolean; } export interface NotificationAgentConfig { @@ -193,12 +206,18 @@ class Settings { applicationTitle: 'Overseerr', applicationUrl: '', csrfProtection: false, + cacheImages: false, defaultPermissions: Permission.REQUEST, + defaultQuotas: { + movie: {}, + tv: {}, + }, hideAvailable: false, localLogin: true, region: '', originalLanguage: '', trustProxy: false, + partialRequestsEnabled: true, }, plex: { name: '', @@ -345,6 +364,8 @@ class Settings { ), region: this.data.main.region, originalLanguage: this.data.main.originalLanguage, + partialRequestsEnabled: this.data.main.partialRequestsEnabled, + cacheImages: this.data.main.cacheImages, }; } diff --git a/server/migration/1616576677254-AddUserQuotaFields.ts b/server/migration/1616576677254-AddUserQuotaFields.ts new file mode 100644 index 00000000..44947bab --- /dev/null +++ b/server/migration/1616576677254-AddUserQuotaFields.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUserQuotaFields1616576677254 implements MigrationInterface { + name = 'AddUserQuotaFields1616576677254'; + + 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, "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, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, "movieQuotaLimit" integer, "movieQuotaDays" integer, "tvQuotaLimit" integer, "tvQuotaDays" integer, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate" 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, "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, "resetPasswordGuid" varchar, "recoveryLinkExpirationDate" date, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))` + ); + await queryRunner.query( + `INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername", "resetPasswordGuid", "recoveryLinkExpirationDate" FROM "temporary_user"` + ); + await queryRunner.query(`DROP TABLE "temporary_user"`); + } +} diff --git a/server/models/Person.ts b/server/models/Person.ts index 522a8e5e..14925edb 100644 --- a/server/models/Person.ts +++ b/server/models/Person.ts @@ -8,6 +8,7 @@ import Media from '../entity/Media'; export interface PersonDetail { id: number; name: string; + birthday: string; deathday: string; knownForDepartment: string; alsoKnownAs?: string[]; @@ -64,6 +65,7 @@ export interface CombinedCredit { export const mapPersonDetails = (person: TmdbPersonDetail): PersonDetail => ({ id: person.id, name: person.name, + birthday: person.birthday, deathday: person.deathday, knownForDepartment: person.known_for_department, alsoKnownAs: person.also_known_as, diff --git a/server/routes/discover.ts b/server/routes/discover.ts index c46048ae..3e690c8e 100644 --- a/server/routes/discover.ts +++ b/server/routes/discover.ts @@ -8,6 +8,9 @@ import { getSettings } from '../lib/settings'; import { User } from '../entity/User'; import { mapProductionCompany } from '../models/Movie'; import { mapNetwork } from '../models/Tv'; +import logger from '../logger'; +import { sortBy } from 'lodash'; +import { GenreSliderItem } from '../interfaces/api/discoverInterfaces'; const createTmdbWithRegionLanaguage = (user?: User): TheMovieDb => { const settings = getSettings(); @@ -482,4 +485,86 @@ discoverRoutes.get<{ keywordId: string }>( } ); +discoverRoutes.get<{ language: string }, GenreSliderItem[]>( + '/genreslider/movie', + async (req, res, next) => { + const tmdb = new TheMovieDb(); + + try { + const mappedGenres: GenreSliderItem[] = []; + + const genres = await tmdb.getMovieGenres({ + language: req.query.language as string, + }); + + await Promise.all( + genres.map(async (genre) => { + const genreData = await tmdb.getDiscoverMovies({ genre: genre.id }); + + mappedGenres.push({ + id: genre.id, + name: genre.name, + backdrops: genreData.results + .filter((title) => !!title.backdrop_path) + .map((title) => title.backdrop_path) as string[], + }); + }) + ); + + const sortedData = sortBy(mappedGenres, 'name'); + + return res.status(200).json(sortedData); + } catch (e) { + logger.error('Something went wrong retrieving the movie genre slider', { + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve movie genre slider.', + }); + } + } +); + +discoverRoutes.get<{ language: string }, GenreSliderItem[]>( + '/genreslider/tv', + async (req, res, next) => { + const tmdb = new TheMovieDb(); + + try { + const mappedGenres: GenreSliderItem[] = []; + + const genres = await tmdb.getTvGenres({ + language: req.query.language as string, + }); + + await Promise.all( + genres.map(async (genre) => { + const genreData = await tmdb.getDiscoverTv({ genre: genre.id }); + + mappedGenres.push({ + id: genre.id, + name: genre.name, + backdrops: genreData.results + .filter((title) => !!title.backdrop_path) + .map((title) => title.backdrop_path) as string[], + }); + }) + ); + + const sortedData = sortBy(mappedGenres, 'name'); + + return res.status(200).json(sortedData); + } catch (e) { + logger.error('Something went wrong retrieving the tv genre slider', { + errorMessage: e.message, + }); + return next({ + status: 500, + message: 'Unable to retrieve tv genre slider.', + }); + } + } +); + export default discoverRoutes; diff --git a/server/routes/request.ts b/server/routes/request.ts index 08597521..b7598f4e 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -1,15 +1,15 @@ import { Router } from 'express'; -import { isAuthenticated } from '../middleware/auth'; -import { Permission } from '../lib/permissions'; import { getRepository } from 'typeorm'; -import { MediaRequest } from '../entity/MediaRequest'; import TheMovieDb from '../api/themoviedb'; +import { MediaRequestStatus, MediaStatus, MediaType } from '../constants/media'; import Media from '../entity/Media'; -import { MediaStatus, MediaRequestStatus, MediaType } from '../constants/media'; +import { MediaRequest } from '../entity/MediaRequest'; import SeasonRequest from '../entity/SeasonRequest'; -import logger from '../logger'; -import { RequestResultsResponse } from '../interfaces/api/requestInterfaces'; import { User } from '../entity/User'; +import { RequestResultsResponse } from '../interfaces/api/requestInterfaces'; +import { Permission } from '../lib/permissions'; +import logger from '../logger'; +import { isAuthenticated } from '../middleware/auth'; const requestRoutes = Router(); @@ -17,6 +17,9 @@ requestRoutes.get('/', async (req, res, next) => { try { const pageSize = req.query.take ? Number(req.query.take) : 10; const skip = req.query.skip ? Number(req.query.skip) : 0; + const requestedBy = req.query.requestedBy + ? Number(req.query.requestedBy) + : null; let statusFilter: MediaRequestStatus[]; @@ -100,9 +103,20 @@ requestRoutes.get('/', async (req, res, next) => { { type: 'or' } ) ) { + if (requestedBy && requestedBy !== req.user?.id) { + return next({ + status: 403, + message: "You do not have permission to view this user's requests.", + }); + } + query = query.andWhere('requestedBy.id = :id', { id: req.user?.id, }); + } else if (requestedBy) { + query = query.andWhere('requestedBy.id = :id', { + id: requestedBy, + }); } const [requests, requestCount] = await query @@ -154,8 +168,29 @@ requestRoutes.post( }); } + if (!requestUser) { + return next({ + status: 500, + message: 'User missing from request context.', + }); + } + + const quotas = await requestUser.getQuota(); + + if (req.body.mediaType === MediaType.MOVIE && quotas.movie.restricted) { + return next({ + status: 403, + message: 'Movie Quota Exceeded', + }); + } else if (req.body.mediaType === MediaType.TV && quotas.tv.restricted) { + return next({ + status: 403, + message: 'Series Quota Exceeded', + }); + } + const tmdbMedia = - req.body.mediaType === 'movie' + req.body.mediaType === MediaType.MOVIE ? await tmdb.getMovie({ movieId: req.body.mediaId }) : await tmdb.getTvShow({ tvId: req.body.mediaId }); @@ -182,7 +217,7 @@ requestRoutes.post( } } - if (req.body.mediaType === 'movie') { + if (req.body.mediaType === MediaType.MOVIE) { const existing = await requestRepository.findOne({ where: { media: { @@ -247,7 +282,7 @@ requestRoutes.post( await requestRepository.save(request); return res.status(201).json(request); - } else if (req.body.mediaType === 'tv') { + } else if (req.body.mediaType === MediaType.TV) { const requestedSeasons = req.body.seasons as number[]; let existingSeasons: number[] = []; @@ -458,14 +493,14 @@ requestRoutes.put<{ requestId: string }>( }); } - if (req.body.mediaType === 'movie') { + if (req.body.mediaType === MediaType.MOVIE) { request.serverId = req.body.serverId; request.profileId = req.body.profileId; request.rootFolder = req.body.rootFolder; request.requestedBy = requestUser as User; requestRepository.save(request); - } else if (req.body.mediaType === 'tv') { + } else if (req.body.mediaType === MediaType.TV) { const mediaRepository = getRepository(Media); request.serverId = req.body.serverId; request.profileId = req.body.profileId; diff --git a/server/routes/settings/index.ts b/server/routes/settings/index.ts index a7dbd3c1..c17e3e13 100644 --- a/server/routes/settings/index.ts +++ b/server/routes/settings/index.ts @@ -1,22 +1,30 @@ import { Router } from 'express'; -import { getSettings, Library, MainSettings } from '../../lib/settings'; +import rateLimit from 'express-rate-limit'; +import fs from 'fs'; +import { merge, omit } from 'lodash'; +import path from 'path'; import { getRepository } from 'typeorm'; -import { User } from '../../entity/User'; import PlexAPI from '../../api/plexapi'; import PlexTvAPI from '../../api/plextv'; -import { scheduledJobs } from '../../job/schedule'; -import { Permission } from '../../lib/permissions'; -import { isAuthenticated } from '../../middleware/auth'; -import { merge, omit } from 'lodash'; import Media from '../../entity/Media'; 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'; +import { User } from '../../entity/User'; +import { + LogMessage, + LogsResultsResponse, + SettingsAboutResponse, +} from '../../interfaces/api/settingsInterfaces'; +import { scheduledJobs } from '../../job/schedule'; import cacheManager, { AvailableCacheIds } from '../../lib/cache'; +import { Permission } from '../../lib/permissions'; import { plexFullScanner } from '../../lib/scanners/plex'; +import { getSettings, Library, MainSettings } from '../../lib/settings'; +import logger from '../../logger'; +import { isAuthenticated } from '../../middleware/auth'; +import { getAppVersion } from '../../utils/appVersion'; +import notificationRoutes from './notifications'; +import radarrRoutes from './radarr'; +import sonarrRoutes from './sonarr'; const settingsRoutes = Router(); @@ -223,6 +231,86 @@ settingsRoutes.post('/plex/sync', (req, res) => { return res.status(200).json(plexFullScanner.status()); }); +settingsRoutes.get( + '/logs', + rateLimit({ windowMs: 60 * 1000, max: 50 }), + (req, res, next) => { + const pageSize = req.query.take ? Number(req.query.take) : 25; + const skip = req.query.skip ? Number(req.query.skip) : 0; + + let filter: string[] = []; + switch (req.query.filter) { + case 'debug': + filter.push('debug'); + // falls through + case 'info': + filter.push('info'); + // falls through + case 'warn': + filter.push('warn'); + // falls through + case 'error': + filter.push('error'); + break; + default: + filter = ['debug', 'info', 'warn', 'error']; + } + + const logFile = process.env.CONFIG_DIRECTORY + ? `${process.env.CONFIG_DIRECTORY}/logs/overseerr.log` + : path.join(__dirname, '../../../config/logs/overseerr.log'); + const logs: LogMessage[] = []; + + try { + fs.readFileSync(logFile) + .toString() + .split('\n') + .forEach((line) => { + if (!line.length) return; + + const timestamp = line.match(new RegExp(/^.{24}/)) || []; + const level = line.match(new RegExp(/\s\[\w+\]/)) || []; + const label = line.match(new RegExp(/\]\[.+?\]/)) || []; + const message = line.match(new RegExp(/:\s([^{}]+)({.*})?/)) || []; + + if (level.length && filter.includes(level[0].slice(2, -1))) { + logs.push({ + timestamp: timestamp[0], + level: level.length ? level[0].slice(2, -1) : '', + label: label.length ? label[0].slice(2, -1) : '', + message: message.length && message[1] ? message[1] : '', + data: + message.length && message[2] + ? JSON.parse(message[2]) + : undefined, + }); + } + }); + + const displayedLogs = logs.reverse().slice(skip, skip + pageSize); + + return res.status(200).json({ + pageInfo: { + pages: Math.ceil(logs.length / pageSize), + pageSize, + results: logs.length, + page: Math.ceil(skip / pageSize) + 1, + }, + results: displayedLogs, + } as LogsResultsResponse); + } catch (error) { + logger.error('Something went wrong while fetching the logs', { + label: 'Logs', + errorMessage: error.message, + }); + return next({ + status: 500, + message: 'Something went wrong while fetching the logs', + }); + } + } +); + settingsRoutes.get('/jobs', (_req, res) => { return res.status(200).json( scheduledJobs.map((job) => ({ diff --git a/server/routes/user/index.ts b/server/routes/user/index.ts index d29567a6..2ddc700f 100644 --- a/server/routes/user/index.ts +++ b/server/routes/user/index.ts @@ -1,16 +1,19 @@ import { Router } from 'express'; +import gravatarUrl from 'gravatar-url'; import { getRepository, Not } from 'typeorm'; import PlexTvAPI from '../../api/plextv'; +import { UserType } from '../../constants/user'; import { MediaRequest } from '../../entity/MediaRequest'; import { User } from '../../entity/User'; +import { + QuotaResponse, + UserRequestsResponse, + UserResultsResponse, +} from '../../interfaces/api/userInterfaces'; import { hasPermission, Permission } from '../../lib/permissions'; import { getSettings } from '../../lib/settings'; import logger from '../../logger'; -import gravatarUrl from 'gravatar-url'; -import { UserType } from '../../constants/user'; import { isAuthenticated } from '../../middleware/auth'; -import { UserResultsResponse } from '../../interfaces/api/userInterfaces'; -import { UserRequestsResponse } from '../../interfaces/api/userInterfaces'; import userSettingsRoutes from './usersettings'; const router = Router(); @@ -380,4 +383,36 @@ router.post( } ); +router.get<{ id: string }, QuotaResponse>( + '/:id/quota', + async (req, res, next) => { + try { + const userRepository = getRepository(User); + + if ( + Number(req.params.id) !== req.user?.id && + !req.user?.hasPermission( + [Permission.MANAGE_USERS, Permission.MANAGE_REQUESTS], + { type: 'and' } + ) + ) { + return next({ + status: 403, + message: 'You do not have permission to access this endpoint.', + }); + } + + const user = await userRepository.findOneOrFail({ + where: { id: Number(req.params.id) }, + }); + + const quotas = await user.getQuota(); + + return res.status(200).json(quotas); + } catch (e) { + next({ status: 404, message: e.message }); + } + } +); + export default router; diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 02ff2c72..693c228e 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -2,13 +2,13 @@ import { Router } from 'express'; import { getRepository } from 'typeorm'; import { canMakePermissionsChange } from '.'; import { User } from '../../entity/User'; -import { getSettings } from '../../lib/settings'; import { UserSettings } from '../../entity/UserSettings'; import { UserSettingsGeneralResponse, UserSettingsNotificationsResponse, } from '../../interfaces/api/userSettingsInterfaces'; import { Permission } from '../../lib/permissions'; +import { getSettings } from '../../lib/settings'; import logger from '../../logger'; import { isAuthenticated } from '../../middleware/auth'; @@ -35,6 +35,9 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>( '/main', isOwnProfileOrAdmin(), async (req, res, next) => { + const { + main: { defaultQuotas }, + } = getSettings(); const userRepository = getRepository(User); try { @@ -50,6 +53,14 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>( username: user.username, region: user.settings?.region, originalLanguage: user.settings?.originalLanguage, + movieQuotaLimit: user.movieQuotaLimit, + movieQuotaDays: user.movieQuotaDays, + tvQuotaLimit: user.tvQuotaLimit, + tvQuotaDays: user.tvQuotaDays, + globalMovieQuotaDays: defaultQuotas.movie.quotaDays, + globalMovieQuotaLimit: defaultQuotas.movie.quotaLimit, + globalTvQuotaDays: defaultQuotas.tv.quotaDays, + globalTvQuotaLimit: defaultQuotas.tv.quotaLimit, }); } catch (e) { next({ status: 500, message: e.message }); @@ -82,6 +93,18 @@ userSettingsRoutes.post< } user.username = req.body.username; + + // Update quota values only if the user has the correct permissions + if ( + !user.hasPermission(Permission.MANAGE_USERS) && + req.user?.id !== user.id + ) { + user.movieQuotaDays = req.body.movieQuotaDays; + user.movieQuotaLimit = req.body.movieQuotaLimit; + user.tvQuotaDays = req.body.tvQuotaDays; + user.tvQuotaLimit = req.body.tvQuotaLimit; + } + if (!user.settings) { user.settings = new UserSettings({ user: req.user, diff --git a/server/templates/email/media-request/html.pug b/server/templates/email/media-request/html.pug index 814fcab0..73985a3e 100644 --- a/server/templates/email/media-request/html.pug +++ b/server/templates/email/media-request/html.pug @@ -42,7 +42,6 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en') table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation') tr td(align='center' style='\ - font-size: 16px;\ padding-top: 25px;\ padding-bottom: 25px;\ text-align: center;\ @@ -50,7 +49,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en') a(href=applicationUrl style='\ text-shadow: 0 1px 0 #ffffff;\ font-weight: 700;\ - font-size: 16px;\ + font-size: 24px;\ color: #a8aaaf;\ text-decoration: none;\ ') @@ -70,13 +69,17 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en') br br p(style='margin-top: 4px; text-align: center') - | #{mediaName} - table(cellpadding='0' cellspacing='0' role='presentation') + b + | #{mediaName} + each extra in mediaExtra + br + | #{extra.name}:  + | #{extra.value} + table(align='center' cellpadding='0' cellspacing='0' role='presentation') tr td - table(cellpadding='0' cellspacing='0' role='presentation') - a(href=actionUrl style='color: #3869d4') - img(src=imageUrl alt='') + a(href=actionUrl style='color: #3869d4') + img(src=imageUrl alt='') p(style='\ font-size: 16px;\ line-height: 24px;\ diff --git a/src/assets/extlogos/pushbullet.svg b/src/assets/extlogos/pushbullet.svg index c241c5d4..e6101705 100644 --- a/src/assets/extlogos/pushbullet.svg +++ b/src/assets/extlogos/pushbullet.svg @@ -1 +1 @@ -image/svg+xml + \ No newline at end of file diff --git a/src/assets/services/plex.svg b/src/assets/services/plex.svg index 5debcdf3..e04ac484 100644 --- a/src/assets/services/plex.svg +++ b/src/assets/services/plex.svg @@ -1 +1 @@ -plex-logo \ No newline at end of file +plex-logo \ No newline at end of file diff --git a/src/assets/services/tvdb.svg b/src/assets/services/tvdb.svg index f9369b4f..da2aa9f9 100644 --- a/src/assets/services/tvdb.svg +++ b/src/assets/services/tvdb.svg @@ -1 +1 @@ -image/svg+xml \ No newline at end of file + \ No newline at end of file diff --git a/src/assets/spinner.svg b/src/assets/spinner.svg index a5ade8a1..76ac31a8 100644 --- a/src/assets/spinner.svg +++ b/src/assets/spinner.svg @@ -1,21 +1 @@ - - - - - - - - - - + \ No newline at end of file diff --git a/src/components/CollectionDetails/index.tsx b/src/components/CollectionDetails/index.tsx index 5953df1e..93447749 100644 --- a/src/components/CollectionDetails/index.tsx +++ b/src/components/CollectionDetails/index.tsx @@ -1,4 +1,6 @@ import axios from 'axios'; +import { uniq } from 'lodash'; +import Link from 'next/link'; import { useRouter } from 'next/router'; import React, { useContext, useState } from 'react'; import { defineMessages, useIntl } from 'react-intl'; @@ -8,35 +10,30 @@ import { MediaStatus } from '../../../server/constants/media'; import type { MediaRequest } from '../../../server/entity/MediaRequest'; import type { Collection } from '../../../server/models/Collection'; import { LanguageContext } from '../../context/LanguageContext'; +import useSettings from '../../hooks/useSettings'; +import { Permission, useUser } from '../../hooks/useUser'; +import globalMessages from '../../i18n/globalMessages'; import Error from '../../pages/_error'; -import StatusBadge from '../StatusBadge'; import ButtonWithDropdown from '../Common/ButtonWithDropdown'; +import CachedImage from '../Common/CachedImage'; import LoadingSpinner from '../Common/LoadingSpinner'; import Modal from '../Common/Modal'; +import PageTitle from '../Common/PageTitle'; import Slider from '../Slider'; +import StatusBadge from '../StatusBadge'; import TitleCard from '../TitleCard'; import Transition from '../Transition'; -import PageTitle from '../Common/PageTitle'; -import { useUser, Permission } from '../../hooks/useUser'; -import useSettings from '../../hooks/useSettings'; -import Link from 'next/link'; -import { uniq } from 'lodash'; const messages = defineMessages({ - overviewunavailable: 'Overview unavailable.', overview: 'Overview', - movies: 'Movies', numberofmovies: '{count} Movies', - requesting: 'Requesting…', - request: 'Request', requestcollection: 'Request Collection', requestswillbecreated: 'The following titles will have requests created for them:', - request4k: 'Request 4K', requestcollection4k: 'Request Collection in 4K', requestswillbecreated4k: 'The following titles will have 4K requests created for them:', - requestSuccess: '{title} successfully requested!', + requestSuccess: '{title} requested successfully!', }); interface CollectionDetailsProps { @@ -192,7 +189,10 @@ const CollectionDetails: React.FC = ({ )) .reduce((prev, curr) => ( <> - {prev}, {curr} + {intl.formatMessage(globalMessages.delimitedlist, { + a: prev, + b: curr, + })} )) ); @@ -203,9 +203,26 @@ const CollectionDetails: React.FC = ({ className="media-page" style={{ height: 493, - backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`, }} > + {data.backdropPath && ( +
+ +
+
+ )} = ({ onOk={() => requestBundle()} okText={ isRequesting - ? intl.formatMessage(messages.requesting) - : intl.formatMessage(is4k ? messages.request4k : messages.request) + ? intl.formatMessage(globalMessages.requesting) + : intl.formatMessage( + is4k ? globalMessages.request4k : globalMessages.request + ) } okDisabled={isRequesting} okButtonType="primary" @@ -268,11 +287,20 @@ const CollectionDetails: React.FC = ({
- +
+ +
= ({ )}
-
-
-

{intl.formatMessage(messages.overview)}

-

- {data.overview - ? data.overview - : intl.formatMessage(messages.overviewunavailable)} -

+ {data.overview && ( +
+
+

{intl.formatMessage(messages.overview)}

+

{data.overview}

+
-
+ )}
- {intl.formatMessage(messages.movies)} + {intl.formatMessage(globalMessages.movies)}
= ({ title, children, type }) => { } return ( -
+
{design.svg}
-
- {title} -
-
{children}
+ {title && ( +
+ {title} +
+ )} + {children && ( +
+ {children} +
+ )}
diff --git a/src/components/Common/Button/index.tsx b/src/components/Common/Button/index.tsx index 411f84d9..fd9b8918 100644 --- a/src/components/Common/Button/index.tsx +++ b/src/components/Common/Button/index.tsx @@ -65,7 +65,7 @@ function Button

( break; case 'success': buttonStyle.push( - 'text-white bg-green-400 border-green-400 hover:bg-green-300 hover:border-green-300 focus:border-green-700 focus:ring-green active:bg-green-700 active:border-green-700' + 'text-white bg-green-500 border-green-500 hover:bg-green-400 hover:border-green-400 focus:border-green-700 focus:ring-green active:bg-green-700 active:border-green-700' ); break; case 'ghost': @@ -75,7 +75,7 @@ function Button

( break; default: buttonStyle.push( - 'text-gray-200 bg-gray-500 border-gray-500 hover:text-white hover:bg-gray-400 hover:border-gray-400 group-hover:text-white group-hover:bg-gray-400 group-hover:border-gray-400 focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-400 active:border-gray-400' + 'text-gray-200 bg-gray-600 border-gray-600 hover:text-white hover:bg-gray-500 hover:border-gray-500 group-hover:text-white group-hover:bg-gray-500 group-hover:border-gray-500 focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-500 active:border-gray-500' ); } diff --git a/src/components/Common/ButtonWithDropdown/index.tsx b/src/components/Common/ButtonWithDropdown/index.tsx index d81ea3fa..d429e114 100644 --- a/src/components/Common/ButtonWithDropdown/index.tsx +++ b/src/components/Common/ButtonWithDropdown/index.tsx @@ -80,7 +80,7 @@ const ButtonWithDropdown: React.FC = ({ } return ( - + {children && ( - +

-
+
@@ -266,9 +267,7 @@ const Sidebar: React.FC = ({ open, setClosed }) => { `} > {sidebarLink.svgIcon} - + {intl.formatMessage(messages[sidebarLink.messagesKey])} ); diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index d4d161d2..330f7b3a 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -1,15 +1,15 @@ -import React, { useEffect, useState } from 'react'; -import SearchInput from './SearchInput'; -import UserDropdown from './UserDropdown'; -import Sidebar from './Sidebar'; -import LanguagePicker from './LanguagePicker'; import { useRouter } from 'next/router'; -import { defineMessages, FormattedMessage } from 'react-intl'; +import React, { useEffect, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; import { Permission, useUser } from '../../hooks/useUser'; +import LanguagePicker from './LanguagePicker'; +import SearchInput from './SearchInput'; +import Sidebar from './Sidebar'; +import UserDropdown from './UserDropdown'; const messages = defineMessages({ alphawarning: - 'This is ALPHA software. Features may be broken and/or unstable. Please report issues on GitHub!', + 'This is ALPHA software. Features may be broken and/or unstable. Please report any issues on GitHub!', }); const Layout: React.FC = ({ children }) => { @@ -17,6 +17,7 @@ const Layout: React.FC = ({ children }) => { const [isScrolled, setIsScrolled] = useState(false); const { hasPermission } = useUser(); const router = useRouter(); + const intl = useIntl(); useEffect(() => { const updateScrolled = () => { @@ -36,14 +37,14 @@ const Layout: React.FC = ({ children }) => { return (
-
+
setSidebarOpen(false)} />
{

- + {intl.formatMessage(messages.alphawarning)}

{

- Logo + Logo

- + {intl.formatMessage(messages.signinheader)}

diff --git a/src/components/MovieDetails/MovieCast/index.tsx b/src/components/MovieDetails/MovieCast/index.tsx index af4e34b9..081a7a6d 100644 --- a/src/components/MovieDetails/MovieCast/index.tsx +++ b/src/components/MovieDetails/MovieCast/index.tsx @@ -45,7 +45,7 @@ const MovieCast: React.FC = () => { {intl.formatMessage(messages.fullcast)}
-
    +
      {data?.credits.cast.map((person, index) => { return (
    • diff --git a/src/components/MovieDetails/MovieCrew/index.tsx b/src/components/MovieDetails/MovieCrew/index.tsx index d5345287..f19cbc20 100644 --- a/src/components/MovieDetails/MovieCrew/index.tsx +++ b/src/components/MovieDetails/MovieCrew/index.tsx @@ -45,7 +45,7 @@ const MovieCrew: React.FC = () => { {intl.formatMessage(messages.fullcrew)}
-