Merge branch 'develop'

pull/2563/head
sct 4 years ago
commit f072a4e11d
No known key found for this signature in database
GPG Key ID: 77D146606D30DCCD

@ -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": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",

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

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

2
.gitignore vendored

@ -32,7 +32,7 @@ yarn-error.log*
.vercel
# database
config/db/*.sqlite3
config/db/*.sqlite3*
config/settings.json
# logs

@ -14,5 +14,9 @@
"name": "Local SQLite",
"database": "./config/db/db.sqlite3"
}
]
],
"editor.codeActionsOnSave": {
"source.organizeImports": true
},
"editor.formatOnSave": true
}

@ -12,7 +12,7 @@
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
<a href="https://github.com/sct/overseerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr"></a>
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-40-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-46-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
</p>
@ -148,8 +148,16 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="https://github.com/JonnyWong16"><img src="https://avatars.githubusercontent.com/u/9099342?v=4?s=100" width="100px;" alt=""/><br /><sub><b>JonnyWong16</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JonnyWong16" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Roxedus"><img src="https://avatars.githubusercontent.com/u/7110194?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Roxedus</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=Roxedus" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/WoisWoi"><img src="https://avatars.githubusercontent.com/u/75491231?v=4?s=100" width="100px;" alt=""/><br /><sub><b>WoisWoi</b></sub></a><br /><a href="#translation-WoisWoi" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/HubDuck"><img src="https://avatars.githubusercontent.com/u/77843475?v=4?s=100" width="100px;" alt=""/><br /><sub><b>HubDuck</b></sub></a><br /><a href="#translation-HubDuck" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/HubDuck"><img src="https://avatars.githubusercontent.com/u/77843475?v=4?s=100" width="100px;" alt=""/><br /><sub><b>HubDuck</b></sub></a><br /><a href="#translation-HubDuck" title="Translation">🌍</a> <a href="https://github.com/sct/overseerr/commits?author=HubDuck" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/costaht"><img src="https://avatars.githubusercontent.com/u/50637431?v=4?s=100" width="100px;" alt=""/><br /><sub><b>costaht</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=costaht" title="Documentation">📖</a> <a href="#translation-costaht" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/Shjosan"><img src="https://avatars.githubusercontent.com/u/20847626?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Shjosan</b></sub></a><br /><a href="#translation-Shjosan" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/kobaubarr"><img src="https://avatars.githubusercontent.com/u/28481522?v=4?s=100" width="100px;" alt=""/><br /><sub><b>kobaubarr</b></sub></a><br /><a href="#translation-kobaubarr" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/notorius28"><img src="https://avatars.githubusercontent.com/u/1621513?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ricardo González</b></sub></a><br /><a href="#translation-notorius28" title="Translation">🌍</a></td>
<td align="center"><a href="http://torkili.uz"><img src="https://avatars.githubusercontent.com/u/460764?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Torkil</b></sub></a><br /><a href="#translation-Torkiliuz" title="Translation">🌍</a></td>
<td align="center"><a href="https://www.jagandeepbrar.io"><img src="https://avatars.githubusercontent.com/u/3048295?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jagandeep Brar</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=JagandeepBrar" title="Documentation">📖</a></td>
<td align="center"><a href="http://dtalens.com"><img src="https://avatars.githubusercontent.com/u/6631832?v=4?s=100" width="100px;" alt=""/><br /><sub><b>dtalens</b></sub></a><br /><a href="#translation-dtalens" title="Translation">🌍</a></td>
</tr>
</table>

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

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

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

@ -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 &rarr; Integrations &rarr; Webhooks**.

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

@ -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)
{% hint style="info" %}
This is typically not needed. Please refer to your webhook provider's documentation for details.
{% endhint %}
Custom authorization header. Anything entered for this will be sent as an `Authorization` header.
This value will be sent as an `Authorization` HTTP header.
### JSON Payload (required)

@ -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 &rarr; General &rarr; 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 &rarr; General &rarr; 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.

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

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

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

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

@ -1,3 +1,6 @@
module.exports = {
plugins: ['tailwindcss', 'postcss-preset-env'],
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@ -1 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg version="1.1" viewBox="0 0 1000 1115.2" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:xlink="http://www.w3.org/1999/xlink"><metadata><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><defs><path id="a" d="m0 0h1024v1024h-1024z"/></defs><use transform="rotate(.704 1914.4 -5491.6)" width="100%" height="100%" fill="#ffffff" fill-opacity="0" xlink:href="#a"/><g transform="matrix(1.1348 0 0 1.1348 -.0011348 -.013738)"><path d="m105.76 154.15-1.263 714.59c-60.261 6.782-105.02-23.858-104.28-84.025l-0.216-594.25c2.312-188.02 175.85-231.02 280.22-154.52l530.2 314.93c74.563 53.572 88.403 151.53 49.965 218.76-6.873-52.739-29.067-83.101-73.823-113.74l-597.52-345.84c-44.756-30.639-82.453-23.58-83.286 44.109zm-54.377 751.54c44.941 15.597 90.16 8.63 128.04-13.47l621.16-353.43c36.958 53.109 28.79 105.66-16.706 135.19l-522.65 294.46c-75.673 36.68-172.98-2.127-209.85-62.757z" fill="#fff"/><path d="m240.52 702.59 365.02-216.68-364.35-197.07z" fill="#ffc230"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 1000 1115.2"><metadata/><defs><path id="a" d="m0 0h1024v1024h-1024z"/></defs><use width="100%" height="100%" fill="#fff" fill-opacity="0" transform="rotate(.704 1914.4 -5491.6)" xlink:href="#a"/><g><path fill="#fff" d="m105.76 154.15-1.263 714.59c-60.261 6.782-105.02-23.858-104.28-84.025l-0.216-594.25c2.312-188.02 175.85-231.02 280.22-154.52l530.2 314.93c74.563 53.572 88.403 151.53 49.965 218.76-6.873-52.739-29.067-83.101-73.823-113.74l-597.52-345.84c-44.756-30.639-82.453-23.58-83.286 44.109zm-54.377 751.54c44.941 15.597 90.16 8.63 128.04-13.47l621.16-353.43c36.958 53.109 28.79 105.66-16.706 135.19l-522.65 294.46c-75.673 36.68-172.98-2.127-209.85-62.757z" transform="matrix(1.1348 0 0 1.1348 -.0011348 -.013738)"/><path fill="#ffc230" d="m240.52 702.59 365.02-216.68-364.35-197.07z" transform="matrix(1.1348 0 0 1.1348 -.0011348 -.013738)"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 969 B

@ -1 +1 @@
<svg height="216.9" viewBox="0 0 216.7 216.9" width="216.7" xmlns="http://www.w3.org/2000/svg"> <path clip-rule="evenodd" d="M216.7 108.45c0 29.833-10.533 55.4-31.6 76.7-.7.833-1.483 1.6-2.35 2.3-3.466 3.4-7.133 6.484-11 9.25-18.267 13.467-39.367 20.2-63.3 20.2-23.967 0-45.033-6.733-63.2-20.2-4.8-3.4-9.3-7.25-13.5-11.55-16.367-16.266-26.417-35.167-30.15-56.7-.733-4.2-1.217-8.467-1.45-12.8-.1-2.4-.15-4.8-.15-7.2 0-2.533.05-4.95.15-7.25 0-.233.066-.467.2-.7 1.567-26.6 12.033-49.583 31.4-68.95C53.05 10.517 78.617 0 108.45 0c29.933 0 55.484 10.517 76.65 31.55 21.067 21.433 31.6 47.067 31.6 76.9z" fill="#EEE" fill-rule="evenodd"/> <path clip-rule="evenodd" d="M194.65 42.5l-22.4 22.4C159.152 77.998 158 89.4 158 109.5c0 17.934 2.852 34.352 16.2 47.7 9.746 9.746 19 18.95 19 18.95-2.5 3.067-5.2 6.067-8.1 9-.7.833-1.483 1.6-2.35 2.3-2.533 2.5-5.167 4.817-7.9 6.95l-17.55-17.55c-15.598-15.6-27.996-17.1-48.6-17.1-19.77 0-33.223 1.822-47.7 16.3-8.647 8.647-18.55 18.6-18.55 18.6-3.767-2.867-7.333-6.034-10.7-9.5-2.8-2.8-5.417-5.667-7.85-8.6 0 0 9.798-9.848 19.15-19.2 13.852-13.853 16.1-29.916 16.1-47.85 0-17.5-2.874-33.823-15.6-46.55-8.835-8.836-21.05-21-21.05-21 2.833-3.6 5.917-7.067 9.25-10.4 2.934-2.867 5.934-5.55 9-8.05L61.1 43.85C74.102 56.852 90.767 60.2 108.7 60.2c18.467 0 35.077-3.577 48.6-17.1 8.32-8.32 19.3-19.25 19.3-19.25 2.9 2.367 5.733 4.933 8.5 7.7 3.467 3.533 6.65 7.183 9.55 10.95z" fill="#3A3F51" fill-rule="evenodd"/> <g clip-rule="evenodd"> <path d="M78.7 114c-.2-1.167-.332-2.35-.4-3.55-.032-.667-.05-1.333-.05-2 0-.7.018-1.367.05-2 0-.067.018-.133.05-.2.435-7.367 3.334-13.733 8.7-19.1 5.9-5.833 12.984-8.75 21.25-8.75 8.3 0 15.384 2.917 21.25 8.75 5.834 5.934 8.75 13.033 8.75 21.3 0 8.267-2.916 15.35-8.75 21.25-.2.233-.416.45-.65.65-.966.933-1.982 1.783-3.05 2.55-5.065 3.733-10.916 5.6-17.55 5.6s-12.466-1.866-17.5-5.6c-1.332-.934-2.582-2-3.75-3.2-4.532-4.5-7.316-9.734-8.35-15.7z" fill="#0CF" fill-rule="evenodd"/> <path d="M157.8 59.75l-15 14.65M30.785 32.526L71.65 73.25m84.6 84.25l27.808 28.78m1.855-153.894L157.8 59.75m-125.45 126l27.35-27.4" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="2"/> <path d="M157.8 59.75l-16.95 17.2M58.97 60.604l17.2 17.15M59.623 158.43l16.75-17.4m61.928-1.396l18.028 17.945" fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="7"/> </g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="216.7" height="216.9" viewBox="0 0 216.7 216.9"><path fill="#EEE" fill-rule="evenodd" d="M216.7 108.45c0 29.833-10.533 55.4-31.6 76.7-.7.833-1.483 1.6-2.35 2.3-3.466 3.4-7.133 6.484-11 9.25-18.267 13.467-39.367 20.2-63.3 20.2-23.967 0-45.033-6.733-63.2-20.2-4.8-3.4-9.3-7.25-13.5-11.55-16.367-16.266-26.417-35.167-30.15-56.7-.733-4.2-1.217-8.467-1.45-12.8-.1-2.4-.15-4.8-.15-7.2 0-2.533.05-4.95.15-7.25 0-.233.066-.467.2-.7 1.567-26.6 12.033-49.583 31.4-68.95C53.05 10.517 78.617 0 108.45 0c29.933 0 55.484 10.517 76.65 31.55 21.067 21.433 31.6 47.067 31.6 76.9z" clip-rule="evenodd"/><path fill="#3A3F51" fill-rule="evenodd" d="M194.65 42.5l-22.4 22.4C159.152 77.998 158 89.4 158 109.5c0 17.934 2.852 34.352 16.2 47.7 9.746 9.746 19 18.95 19 18.95-2.5 3.067-5.2 6.067-8.1 9-.7.833-1.483 1.6-2.35 2.3-2.533 2.5-5.167 4.817-7.9 6.95l-17.55-17.55c-15.598-15.6-27.996-17.1-48.6-17.1-19.77 0-33.223 1.822-47.7 16.3-8.647 8.647-18.55 18.6-18.55 18.6-3.767-2.867-7.333-6.034-10.7-9.5-2.8-2.8-5.417-5.667-7.85-8.6 0 0 9.798-9.848 19.15-19.2 13.852-13.853 16.1-29.916 16.1-47.85 0-17.5-2.874-33.823-15.6-46.55-8.835-8.836-21.05-21-21.05-21 2.833-3.6 5.917-7.067 9.25-10.4 2.934-2.867 5.934-5.55 9-8.05L61.1 43.85C74.102 56.852 90.767 60.2 108.7 60.2c18.467 0 35.077-3.577 48.6-17.1 8.32-8.32 19.3-19.25 19.3-19.25 2.9 2.367 5.733 4.933 8.5 7.7 3.467 3.533 6.65 7.183 9.55 10.95z" clip-rule="evenodd"/><g clip-rule="evenodd"><path fill="#0CF" fill-rule="evenodd" d="M78.7 114c-.2-1.167-.332-2.35-.4-3.55-.032-.667-.05-1.333-.05-2 0-.7.018-1.367.05-2 0-.067.018-.133.05-.2.435-7.367 3.334-13.733 8.7-19.1 5.9-5.833 12.984-8.75 21.25-8.75 8.3 0 15.384 2.917 21.25 8.75 5.834 5.934 8.75 13.033 8.75 21.3 0 8.267-2.916 15.35-8.75 21.25-.2.233-.416.45-.65.65-.966.933-1.982 1.783-3.05 2.55-5.065 3.733-10.916 5.6-17.55 5.6s-12.466-1.866-17.5-5.6c-1.332-.934-2.582-2-3.75-3.2-4.532-4.5-7.316-9.734-8.35-15.7z"/><path fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="2" d="M157.8 59.75l-15 14.65M30.785 32.526L71.65 73.25m84.6 84.25l27.808 28.78m1.855-153.894L157.8 59.75m-125.45 126l27.35-27.4"/><path fill="none" stroke="#0CF" stroke-miterlimit="1" stroke-width="7" d="M157.8 59.75l-16.95 17.2M58.97 60.604l17.2 17.15M59.623 158.43l16.75-17.4m61.928-1.396l18.028 17.945"/></g></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

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

@ -118,7 +118,7 @@ class PlexAPI {
options: {
identifier: settings.clientId,
product: 'Overseerr',
deviceName: settings.main.applicationTitle,
deviceName: 'Overseerr',
platform: 'Overseerr',
},
});

@ -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}`

@ -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[];

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

@ -1,28 +1,34 @@
import bcrypt from 'bcrypt';
import path from 'path';
import { default as generatePassword } from 'secure-random-password';
import {
Entity,
PrimaryGeneratedColumn,
AfterLoad,
Column,
CreateDateColumn,
UpdateDateColumn,
Entity,
getRepository,
MoreThan,
Not,
OneToMany,
RelationCount,
AfterLoad,
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 {
Permission,
hasPermission,
Permission,
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 { v4 as uuid } from 'uuid';
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<QuotaResponse> {
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,
},
};
}
}

@ -0,0 +1,5 @@
export interface GenreSliderItem {
id: number;
name: string;
backdrops: string[];
}

@ -1,3 +1,17 @@
import type { PaginatedResponse } from './common';
export type LogMessage = {
timestamp: string;
level: string;
label: string;
message: string;
data?: Record<string, unknown>;
};
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 {

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

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

@ -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<T extends NotificationAgentConfig> {
}
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<boolean>;
}

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

@ -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 { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User';
import { Permission } from '../../permissions';
import logger from '../../../logger';
import PreparedEmail from '../../email';
import { MediaType } from '../../../constants/media';
import { Permission } from '../../permissions';
import { getSettings, NotificationAgentEmail } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
class EmailAgent
extends BaseAgent<NotificationAgentEmail>
@ -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', {

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

@ -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 += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
message += `<small>\n${plot}</small>`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nPending Approval`;
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nPending Approval</small>`;
break;
case Notification.MEDIA_APPROVED:
messageTitle = `${
@ -81,10 +81,10 @@ class PushoverAgent
} Request Approved`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
message += `<small>\n${plot}</small>`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nProcessing`;
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nProcessing</small>`;
break;
case Notification.MEDIA_AUTO_APPROVED:
messageTitle = `${
@ -92,10 +92,10 @@ class PushoverAgent
} Request Automatically Approved`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
message += `<small>\n${plot}</small>`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nProcessing`;
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nProcessing</small>`;
break;
case Notification.MEDIA_AVAILABLE:
messageTitle = `${
@ -103,10 +103,10 @@ class PushoverAgent
} Now Available`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
message += `<small>\n${plot}</small>`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nAvailable`;
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nAvailable</small>`;
break;
case Notification.MEDIA_DECLINED:
messageTitle = `${
@ -114,10 +114,10 @@ class PushoverAgent
} Request Declined`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
message += `<small>\n${plot}</small>`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nDeclined`;
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nDeclined</small>`;
priority = 1;
break;
case Notification.MEDIA_FAILED:
@ -126,18 +126,22 @@ class PushoverAgent
} Request`;
message += `<b>${title}</b>`;
if (plot) {
message += `\n${plot}`;
message += `<small>\n${plot}</small>`;
}
message += `\n\n<b>Requested By</b>\n${username}`;
message += `\n\n<b>Status</b>\nFailed`;
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nFailed</small>`;
priority = 1;
break;
case Notification.TEST_NOTIFICATION:
messageTitle = 'Test Notification';
message += `${plot}`;
message += `<small>${plot}</small>`;
break;
}
for (const extra of payload.extra ?? []) {
message += `<small>\n\n<b>${extra.name}</b>\n${extra.value}</small>`;
}
if (settings.main.applicationUrl && payload.media) {
url = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
url_title = `Open in ${settings.main.applicationTitle}`;

@ -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}`;
}

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

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

@ -165,7 +165,7 @@ class BaseScanner<T> {
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 {

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

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

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

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

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

@ -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 Media from '../../entity/Media';
import { MediaRequest } from '../../entity/MediaRequest';
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 { 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 cacheManager, { AvailableCacheIds } from '../../lib/cache';
import { plexFullScanner } from '../../lib/scanners/plex';
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) => ({

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

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

@ -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}:&nbsp;
| #{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;\

@ -1 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg preserveAspectRatio="xMidYMid" version="1.1" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><path d="m128 0c-70.692 0-128 57.308-128 128s57.308 128 128 128 128-57.308 128-128-57.308-128-128-128zm-51 64.268h3.334c16.733 0 16.732-5.78e-4 16.732 16.732v91.867c-1e-6 16.733 5.77e-4 16.732-16.732 16.732h-3.334c-16.733 0-16.732 5.8e-4 -16.732-16.732v-91.867c0-16.733-5.78e-4 -16.732 16.732-16.732zm44.041 0h37.537c32.178 0 52.627 32.273 52.627 63.025 0 30.752-20.627 62.307-52.627 62.307h-37.537c-5.699 0-8.5078-2.8088-8.5078-8.5078v-108.32c0-5.698 2.8088-8.5059 8.5078-8.5059z" fill="#fff" mask="url(#mask-2)"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid" version="1.1" viewBox="0 0 256 256"><metadata/><path fill="#fff" d="m128 0c-70.692 0-128 57.308-128 128s57.308 128 128 128 128-57.308 128-128-57.308-128-128-128zm-51 64.268h3.334c16.733 0 16.732-5.78e-4 16.732 16.732v91.867c-1e-6 16.733 5.77e-4 16.732-16.732 16.732h-3.334c-16.733 0-16.732 5.8e-4 -16.732-16.732v-91.867c0-16.733-5.78e-4 -16.732 16.732-16.732zm44.041 0h37.537c32.178 0 52.627 32.273 52.627 63.025 0 30.752-20.627 62.307-52.627 62.307h-37.537c-5.699 0-8.5078-2.8088-8.5078-8.5078v-108.32c0-5.698 2.8088-8.5059 8.5078-8.5059z" mask="url(#mask-2)"/></svg>

Before

Width:  |  Height:  |  Size: 992 B

After

Width:  |  Height:  |  Size: 639 B

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Layer_1" data-name="Layer 1" viewBox="0 0 320.03 103.61" style="&#10;"><defs><style>.cls-1{fill:#fff;}.cls-2{fill:url(#radial-gradient);}.cls-3{fill:#e5a00d;}</style><radialGradient id="radial-gradient" cx="258.33" cy="51.76" r="42.95" gradientUnits="userSpaceOnUse"><stop offset="0.17" stop-color="#f9be03"/><stop offset="0.51" stop-color="#e8a50b"/><stop offset="1" stop-color="#cc7c19"/></radialGradient></defs><title>plex-logo</title><polygon id="X" class="cls-1" points="320.03 -0.09 289.96 -0.09 259.88 51.76 289.96 103.61 320.01 103.61 289.96 51.79 320.03 -0.09"/><g id="chevron"><polygon class="cls-2" points="226.7 -0.09 256.78 -0.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76 226.7 -0.09"/><polygon class="cls-3" points="226.7 -0.09 256.78 -0.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76 226.7 -0.09"/></g><path id="E" class="cls-1" d="M216.32,103.61H156.49V-.09h59.83v18h-37.8V40.69H213.7v18H178.52V85.45h37.8Z"/><path id="L" class="cls-1" d="M82.07,103.61V-.09h22V85.45h42.07v18.16Z"/><path id="P" class="cls-1" d="M71.66,32.25Q71.66,49,61.2,57.87T31.44,66.73H22v36.88H0V-.09H33.14Q52-.09,61.83,8T71.66,32.25ZM22,48.71h7.24q10.15,0,15.18-4c3.37-2.66,5-6.56,5-11.67s-1.41-9-4.22-11.42S38,17.93,32,17.93H22Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Layer_1" data-name="Layer 1" viewBox="0 0 320.03 103.61"><defs><style>.cls-1{fill:#fff}.cls-2{fill:url(#radial-gradient)}.cls-3{fill:#e5a00d}</style><radialGradient id="radial-gradient" cx="258.33" cy="51.76" r="42.95" gradientUnits="userSpaceOnUse"><stop offset=".17" stop-color="#f9be03"/><stop offset=".51" stop-color="#e8a50b"/><stop offset="1" stop-color="#cc7c19"/></radialGradient></defs><title>plex-logo</title><polygon id="X" points="320.03 -.09 289.96 -.09 259.88 51.76 289.96 103.61 320.01 103.61 289.96 51.79 320.03 -.09" class="cls-1"/><g id="chevron"><polygon points="226.7 -.09 256.78 -.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76 226.7 -.09" class="cls-2"/><polygon points="226.7 -.09 256.78 -.09 289.96 51.76 256.78 103.61 226.7 103.61 259.88 51.76 226.7 -.09" class="cls-3"/></g><path id="E" d="M216.32,103.61H156.49V-.09h59.83v18h-37.8V40.69H213.7v18H178.52V85.45h37.8Z" class="cls-1"/><path id="L" d="M82.07,103.61V-.09h22V85.45h42.07v18.16Z" class="cls-1"/><path id="P" d="M71.66,32.25Q71.66,49,61.2,57.87T31.44,66.73H22v36.88H0V-.09H33.14Q52-.09,61.83,8T71.66,32.25ZM22,48.71h7.24q10.15,0,15.18-4c3.37-2.66,5-6.56,5-11.67s-1.41-9-4.22-11.42S38,17.93,32,17.93H22Z" class="cls-1"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

@ -1 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg version="1.1" viewBox="0 0 49.994049 27.764576" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><metadata><rdf:RDF><cc:Work rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/><dc:title/></cc:Work></rdf:RDF></metadata><g transform="translate(-80.836 -134.28)" stroke-width=".26458"><ellipse cx="104.39" cy="140.67" rx="3.0402" ry="2.5725" fill="#fff"/><path transform="translate(-8.3333e-8)" d="m85.973 162.05c-0.2783-9e-3 -0.76695-0.12188-1.0859-0.25035-0.31894-0.12847-0.75831-0.50788-0.97638-0.84312-0.27168-0.41768-0.81789-4.2912-1.7352-12.306-0.7363-6.4328-1.3387-11.951-1.3387-12.263 0-0.31189 0.36978-0.9125 0.82173-1.3347 0.55888-0.52208 1.0976-0.76762 1.6843-0.76762 0.4744 0 5.7899 0.65473 11.812 1.4549 6.0223 0.80022 11.395 1.5787 11.94 1.73 0.67007 0.18601 1.1196 0.52432 1.3891 1.0455 0.23816 0.46053 0.39842 1.4812 0.39842 2.5375 0 1.6585-0.0587 1.832-0.95642 2.8256-0.52604 0.58226-1.2554 1.7118-1.6209 2.51-0.56065 1.2246-0.6448 1.7259-0.5387 3.2089 0.0692 0.96664 0.33525 2.1733 0.59131 2.6815 0.25605 0.50816 0.9453 1.4214 1.5317 2.0295 1.0049 1.0421 1.0581 1.1768 0.92626 2.3462-0.0925 0.82058-0.35123 1.4496-0.76415 1.8577-0.57063 0.56398-1.5248 0.74328-11.099 2.0855-5.7608 0.80767-10.702 1.461-10.98 1.4517zm5.8777-7.1419c0.64371 9e-3 1.2219-0.14144 1.4082-0.36594 0.17385-0.20948 0.27773-0.71409 0.23083-1.1214-0.07317-0.63544-0.23231-0.76418-1.122-0.90763-0.5702-0.0919-1.1992-0.38942-1.3979-0.66108-0.25954-0.35495-0.34007-1.2022-0.2862-3.011l0.07497-2.517h3.7011l1.7217 4.2333 1.7217 4.2333 1.4787 0.0781c0.97936 0.0517 1.5672-0.0376 1.7409-0.26458 0.14423-0.18847 1.1888-2.5136 2.3212-5.1671 1.1324-2.6534 1.9897-5.0049 1.905-5.2255-0.0964-0.25118-0.49511-0.40113-1.0666-0.40113-0.50198 0-1.0918 0.0687-1.3108 0.15275-0.21893 0.084-1.0155 1.6914-1.7701 3.5719-0.75462 1.8805-1.4697 3.4191-1.589 3.4191-0.1193 0-0.45209-0.62507-0.73953-1.3891-0.28745-0.76398-0.8376-2.2224-1.2226-3.2408s-0.81858-1.971-0.9636-2.1167-1.5382-0.32442-3.0959-0.39718l-2.8323-0.13229-0.13229-1.8521-0.13229-1.8521h-2.6458l-0.13229 1.8365c-0.13098 1.8183-0.14096 1.8388-1.0075 2.062-0.81451 0.20989-0.86954 0.29343-0.79375 1.205 0.07128 0.85741 0.18035 0.99633 0.87518 1.1147l0.79375 0.13524 0.13229 3.2208c0.11196 2.726 0.2174 3.3355 0.68624 3.967 0.30467 0.4104 0.95952 0.88829 1.4552 1.062s1.3927 0.32252 1.9933 0.33073zm12.666-11.891c0.45112 0 0.96309-0.14287 1.1377-0.3175 0.17463-0.17462 0.3175-0.64237 0.3175-1.0394 0-0.39707-0.1871-0.90903-0.41577-1.1377-0.22868-0.22868-0.69642-0.41577-1.0394-0.41577-0.34301 0-0.81075 0.18709-1.0394 0.41577s-0.41577 0.74064-0.41577 1.1377c0 0.39706 0.14287 0.86481 0.3175 1.0394 0.17462 0.17463 0.68659 0.3175 1.1377 0.3175z" fill="#1b7d3d"/><path transform="translate(-8.3333e-8)" d="m114.46 154.52c-3.0274 0.0544-3.7379-0.0118-4.6609-0.43422-0.59897-0.27412-1.3995-0.90545-1.779-1.403-0.37946-0.49751-0.83257-1.5225-1.0069-2.2778-0.24624-1.0668-0.24624-1.6796 0-2.7464 0.17434-0.75526 0.61766-1.7674 0.98516-2.2492 0.3675-0.48183 1.049-1.0738 1.5144-1.3155 0.48523-0.25197 1.8056-0.5058 3.0952-0.59503l2.249-0.15561 0.13229-1.8521 0.1323-1.8521h2.9104v14.817zm-0.92604-2.3736 1.3229-0.0718v-5.8208l-1.6062-0.0775c-1.4422-0.0696-1.6785-5e-3 -2.3151 0.6314-0.38991 0.38991-0.72779 1.0201-0.75085 1.4004-0.023 0.38034-0.0238 0.92965-2e-3 1.2207 0.0222 0.29104 0.10377 0.80075 0.18131 1.1327 0.0775 0.33193 0.52474 0.8405 0.99377 1.1302 0.615 0.37978 1.2217 0.50661 2.1757 0.45481zm9.7151 2.4359c-2.5936 4e-3 -3.5477-0.0819-3.6425-0.32887-0.0706-0.18407-0.0967-3.5476-0.0578-7.4745l0.0706-7.1398h2.9104l0.13229 1.8521 0.13229 1.8521 2.2687 0.15541c1.6342 0.11195 2.5591 0.31887 3.3073 0.73989 0.57124 0.32147 1.3303 1.0254 1.6868 1.5644 0.45872 0.69347 0.67992 1.4556 0.75677 2.6074 0.0597 0.89511-0.0338 2.059-0.20789 2.5864-0.17406 0.52742-0.62874 1.33-1.0104 1.7836-0.38165 0.45358-1.1751 1.0432-1.7632 1.3104-0.86286 0.39194-1.7476 0.48683-4.5833 0.49152zm0.84267-2.3754c0.64994 0 1.4871-0.1161 1.8603-0.25799 0.3732-0.14189 0.92337-0.64791 1.2226-1.1245 0.41645-0.66331 0.5175-1.173 0.43097-2.1738-0.0877-1.0148-0.27505-1.4467-0.83705-1.9301-0.61948-0.53286-0.95861-0.6115-2.3491-0.54474l-1.6251 0.078-0.0763 2.627c-0.042 1.4448-0.0159 2.7843 0.0578 2.9766 0.0835 0.21749 0.5806 0.34956 1.3158 0.34956z" fill="#fff"/></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 49.994 27.765"><metadata/><g stroke-width=".265" transform="translate(-80.836 -134.28)"><ellipse cx="104.39" cy="140.67" fill="#fff" rx="3.04" ry="2.572"/><path fill="#1b7d3d" d="m85.973 162.05c-0.2783-9e-3 -0.76695-0.12188-1.0859-0.25035-0.31894-0.12847-0.75831-0.50788-0.97638-0.84312-0.27168-0.41768-0.81789-4.2912-1.7352-12.306-0.7363-6.4328-1.3387-11.951-1.3387-12.263 0-0.31189 0.36978-0.9125 0.82173-1.3347 0.55888-0.52208 1.0976-0.76762 1.6843-0.76762 0.4744 0 5.7899 0.65473 11.812 1.4549 6.0223 0.80022 11.395 1.5787 11.94 1.73 0.67007 0.18601 1.1196 0.52432 1.3891 1.0455 0.23816 0.46053 0.39842 1.4812 0.39842 2.5375 0 1.6585-0.0587 1.832-0.95642 2.8256-0.52604 0.58226-1.2554 1.7118-1.6209 2.51-0.56065 1.2246-0.6448 1.7259-0.5387 3.2089 0.0692 0.96664 0.33525 2.1733 0.59131 2.6815 0.25605 0.50816 0.9453 1.4214 1.5317 2.0295 1.0049 1.0421 1.0581 1.1768 0.92626 2.3462-0.0925 0.82058-0.35123 1.4496-0.76415 1.8577-0.57063 0.56398-1.5248 0.74328-11.099 2.0855-5.7608 0.80767-10.702 1.461-10.98 1.4517zm5.8777-7.1419c0.64371 9e-3 1.2219-0.14144 1.4082-0.36594 0.17385-0.20948 0.27773-0.71409 0.23083-1.1214-0.07317-0.63544-0.23231-0.76418-1.122-0.90763-0.5702-0.0919-1.1992-0.38942-1.3979-0.66108-0.25954-0.35495-0.34007-1.2022-0.2862-3.011l0.07497-2.517h3.7011l1.7217 4.2333 1.7217 4.2333 1.4787 0.0781c0.97936 0.0517 1.5672-0.0376 1.7409-0.26458 0.14423-0.18847 1.1888-2.5136 2.3212-5.1671 1.1324-2.6534 1.9897-5.0049 1.905-5.2255-0.0964-0.25118-0.49511-0.40113-1.0666-0.40113-0.50198 0-1.0918 0.0687-1.3108 0.15275-0.21893 0.084-1.0155 1.6914-1.7701 3.5719-0.75462 1.8805-1.4697 3.4191-1.589 3.4191-0.1193 0-0.45209-0.62507-0.73953-1.3891-0.28745-0.76398-0.8376-2.2224-1.2226-3.2408s-0.81858-1.971-0.9636-2.1167-1.5382-0.32442-3.0959-0.39718l-2.8323-0.13229-0.13229-1.8521-0.13229-1.8521h-2.6458l-0.13229 1.8365c-0.13098 1.8183-0.14096 1.8388-1.0075 2.062-0.81451 0.20989-0.86954 0.29343-0.79375 1.205 0.07128 0.85741 0.18035 0.99633 0.87518 1.1147l0.79375 0.13524 0.13229 3.2208c0.11196 2.726 0.2174 3.3355 0.68624 3.967 0.30467 0.4104 0.95952 0.88829 1.4552 1.062s1.3927 0.32252 1.9933 0.33073zm12.666-11.891c0.45112 0 0.96309-0.14287 1.1377-0.3175 0.17463-0.17462 0.3175-0.64237 0.3175-1.0394 0-0.39707-0.1871-0.90903-0.41577-1.1377-0.22868-0.22868-0.69642-0.41577-1.0394-0.41577-0.34301 0-0.81075 0.18709-1.0394 0.41577s-0.41577 0.74064-0.41577 1.1377c0 0.39706 0.14287 0.86481 0.3175 1.0394 0.17462 0.17463 0.68659 0.3175 1.1377 0.3175z" transform="translate(-8.3333e-8)"/><path fill="#fff" d="m114.46 154.52c-3.0274 0.0544-3.7379-0.0118-4.6609-0.43422-0.59897-0.27412-1.3995-0.90545-1.779-1.403-0.37946-0.49751-0.83257-1.5225-1.0069-2.2778-0.24624-1.0668-0.24624-1.6796 0-2.7464 0.17434-0.75526 0.61766-1.7674 0.98516-2.2492 0.3675-0.48183 1.049-1.0738 1.5144-1.3155 0.48523-0.25197 1.8056-0.5058 3.0952-0.59503l2.249-0.15561 0.13229-1.8521 0.1323-1.8521h2.9104v14.817zm-0.92604-2.3736 1.3229-0.0718v-5.8208l-1.6062-0.0775c-1.4422-0.0696-1.6785-5e-3 -2.3151 0.6314-0.38991 0.38991-0.72779 1.0201-0.75085 1.4004-0.023 0.38034-0.0238 0.92965-2e-3 1.2207 0.0222 0.29104 0.10377 0.80075 0.18131 1.1327 0.0775 0.33193 0.52474 0.8405 0.99377 1.1302 0.615 0.37978 1.2217 0.50661 2.1757 0.45481zm9.7151 2.4359c-2.5936 4e-3 -3.5477-0.0819-3.6425-0.32887-0.0706-0.18407-0.0967-3.5476-0.0578-7.4745l0.0706-7.1398h2.9104l0.13229 1.8521 0.13229 1.8521 2.2687 0.15541c1.6342 0.11195 2.5591 0.31887 3.3073 0.73989 0.57124 0.32147 1.3303 1.0254 1.6868 1.5644 0.45872 0.69347 0.67992 1.4556 0.75677 2.6074 0.0597 0.89511-0.0338 2.059-0.20789 2.5864-0.17406 0.52742-0.62874 1.33-1.0104 1.7836-0.38165 0.45358-1.1751 1.0432-1.7632 1.3104-0.86286 0.39194-1.7476 0.48683-4.5833 0.49152zm0.84267-2.3754c0.64994 0 1.4871-0.1161 1.8603-0.25799 0.3732-0.14189 0.92337-0.64791 1.2226-1.1245 0.41645-0.66331 0.5175-1.173 0.43097-2.1738-0.0877-1.0148-0.27505-1.4467-0.83705-1.9301-0.61948-0.53286-0.95861-0.6115-2.3491-0.54474l-1.6251 0.078-0.0763 2.627c-0.042 1.4448-0.0159 2.7843 0.0578 2.9766 0.0835 0.21749 0.5806 0.34956 1.3158 0.34956z" transform="translate(-8.3333e-8)"/></g></svg>

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

@ -1,21 +1 @@
<svg
viewBox="-2 -2 42 42"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
>
<g fill="none" fillRule="evenodd">
<g transform="translate(1 1)" stroke-width="6">
<circle stroke-opacity=".5" cx="18" cy="18" r="18" />
<path d="M36 18c0-9.94-8.06-18-18-18">
<animateTransform
attributeName="transform"
type="rotate"
from="0 18 18"
to="360 18 18"
dur="1s"
repeatCount="indefinite"
/>
</path>
</g>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" stroke="currentColor" viewBox="-2 -2 42 42"><g fill="none" fillRule="evenodd"><g stroke-width="6" transform="translate(1 1)"><circle cx="18" cy="18" r="18" stroke-opacity=".5"/><path d="M36 18c0-9.94-8.06-18-18-18"><animateTransform attributeName="transform" dur="1s" from="0 18 18" repeatCount="indefinite" to="360 18 18" type="rotate"/></path></g></g></svg>

Before

Width:  |  Height:  |  Size: 524 B

After

Width:  |  Height:  |  Size: 399 B

@ -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: '<strong>{title}</strong> successfully requested!',
requestSuccess: '<strong>{title}</strong> requested successfully!',
});
interface CollectionDetailsProps {
@ -192,7 +189,10 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
))
.reduce((prev, curr) => (
<>
{prev}, {curr}
{intl.formatMessage(globalMessages.delimitedlist, {
a: prev,
b: curr,
})}
</>
))
);
@ -203,9 +203,26 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
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 && (
<div className="media-page-bg-image">
<CachedImage
alt=""
src={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath}`}
layout="fill"
objectFit="cover"
priority
/>
<div
className="absolute inset-0"
style={{
backgroundImage:
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)',
}}
/>
</div>
)}
<PageTitle title={data.name} />
<Transition
enter="opacity-0 transition duration-300"
@ -220,8 +237,10 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
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<CollectionDetailsProps> = ({
</Modal>
</Transition>
<div className="media-header">
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
alt=""
className="media-poster"
/>
<div className="media-poster">
<CachedImage
src={
data.posterPath
? `https://image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`
: '/images/overseerr_poster_not_found.png'
}
alt=""
layout="responsive"
width={600}
height={900}
priority
/>
</div>
<div className="media-title">
<div className="media-status">
<StatusBadge
@ -388,19 +416,17 @@ const CollectionDetails: React.FC<CollectionDetailsProps> = ({
)}
</div>
</div>
<div className="media-overview">
<div className="flex-1">
<h2>{intl.formatMessage(messages.overview)}</h2>
<p>
{data.overview
? data.overview
: intl.formatMessage(messages.overviewunavailable)}
</p>
{data.overview && (
<div className="media-overview">
<div className="flex-1">
<h2>{intl.formatMessage(messages.overview)}</h2>
<p>{data.overview}</p>
</div>
</div>
</div>
)}
<div className="slider-header">
<div className="slider-title">
<span>{intl.formatMessage(messages.movies)}</span>
<span>{intl.formatMessage(globalMessages.movies)}</span>
</div>
</div>
<Slider

@ -1,7 +1,7 @@
import React from 'react';
interface AlertProps {
title: string;
title?: React.ReactNode;
type?: 'warning' | 'info' | 'error';
}
@ -77,14 +77,20 @@ const Alert: React.FC<AlertProps> = ({ title, children, type }) => {
}
return (
<div className={`rounded-md p-4 mb-5 ${design.bgColor}`}>
<div className={`rounded-md p-4 mb-4 ${design.bgColor}`}>
<div className="flex">
<div className={`flex-shrink-0 ${design.titleColor}`}>{design.svg}</div>
<div className="ml-3">
<div className={`text-sm font-medium ${design.titleColor}`}>
{title}
</div>
<div className={`mt-2 text-sm ${design.textColor}`}>{children}</div>
{title && (
<div className={`text-sm font-medium ${design.titleColor}`}>
{title}
</div>
)}
{children && (
<div className={`mt-2 first:mt-0 text-sm ${design.textColor}`}>
{children}
</div>
)}
</div>
</div>
</div>

@ -65,7 +65,7 @@ function Button<P extends ElementTypes = '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<P extends ElementTypes = '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'
);
}

@ -80,7 +80,7 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
}
return (
<span className="relative z-0 inline-flex h-full rounded-md shadow-sm">
<span className="relative inline-flex h-full rounded-md shadow-sm">
<button
type="button"
className={`relative inline-flex h-full items-center px-4 py-2 text-sm leading-5 font-medium z-10 hover:z-20 focus:z-20 focus:outline-none transition ease-in-out duration-150 ${
@ -92,10 +92,10 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
{text}
</button>
{children && (
<span className="relative z-10 block -ml-px">
<span className="relative block -ml-px">
<button
type="button"
className={`relative inline-flex items-center h-full px-2 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out rounded-r-md focus:z-10 ${styleClasses.dropdownSideButtonClasses}`}
className={`relative inline-flex items-center h-full px-2 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out rounded-r-md z-10 hover:z-20 focus:z-20 ${styleClasses.dropdownSideButtonClasses}`}
aria-label="Expand"
onClick={() => setIsOpen((state) => !state)}
>
@ -125,7 +125,7 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<div className="absolute right-0 w-56 mt-2 -mr-1 origin-top-right rounded-md shadow-lg">
<div className="absolute right-0 z-40 w-56 mt-2 -mr-1 origin-top-right rounded-md shadow-lg">
<div
className={`rounded-md ring-1 ring-black ring-opacity-5 ${styleClasses.dropdownClasses}`}
>

@ -0,0 +1,18 @@
import Image, { ImageProps } from 'next/image';
import React from 'react';
import useSettings from '../../../hooks/useSettings';
/**
* The CachedImage component should be used wherever
* we want to offer the option to locally cache images.
*
* It uses the `next/image` Image component but overrides
* the `unoptimized` prop based on the application setting `cacheImages`.
**/
const CachedImage: React.FC<ImageProps> = (props) => {
const { currentSettings } = useSettings();
return <Image unoptimized={!currentSettings.cacheImages} {...props} />;
};
export default CachedImage;

@ -1,14 +1,16 @@
import React, {
useState,
useEffect,
HTMLAttributes,
ForwardRefRenderFunction,
HTMLAttributes,
useEffect,
useState,
} from 'react';
import CachedImage from '../CachedImage';
interface ImageFaderProps extends HTMLAttributes<HTMLDivElement> {
backgroundImages: string[];
rotationSpeed?: number;
isDarker?: boolean;
forceOptimize?: boolean;
}
const DEFAULT_ROTATION_SPEED = 6000;
@ -18,6 +20,7 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
backgroundImages,
rotationSpeed = DEFAULT_ROTATION_SPEED,
isDarker,
forceOptimize,
...props
},
ref
@ -43,19 +46,37 @@ const ImageFader: ForwardRefRenderFunction<HTMLDivElement, ImageFaderProps> = (
'linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%)';
}
let overrides = {};
if (forceOptimize) {
overrides = {
unoptimized: false,
};
}
return (
<div ref={ref}>
{backgroundImages.map((imageUrl, i) => (
<div
key={`banner-image-${i}`}
className={`absolute inset-0 bg-cover bg-center transition-opacity duration-300 ease-in ${
className={`absolute absolute-top-shift inset-0 bg-cover bg-center transition-opacity duration-300 ease-in ${
i === activeIndex ? 'opacity-100' : 'opacity-0'
}`}
style={{
backgroundImage: `${gradient}, url(${imageUrl})`,
}}
{...props}
/>
>
<CachedImage
className="absolute inset-0 w-full h-full"
alt=""
src={imageUrl}
layout="fill"
objectFit="cover"
{...overrides}
/>
<div
className="absolute inset-0"
style={{ backgroundImage: gradient }}
/>
</div>
))}
</div>
);

@ -1,17 +1,14 @@
import React from 'react';
import { useIntl } from 'react-intl';
import {
TvResult,
MovieResult,
PersonResult,
TvResult,
} from '../../../../server/models/Search';
import TitleCard from '../../TitleCard';
import useVerticalScroll from '../../../hooks/useVerticalScroll';
import globalMessages from '../../../i18n/globalMessages';
import PersonCard from '../../PersonCard';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
noresults: 'No results.',
});
import TitleCard from '../../TitleCard';
interface ListViewProps {
items?: (TvResult | MovieResult | PersonResult)[];
@ -34,10 +31,10 @@ const ListView: React.FC<ListViewProps> = ({
<>
{isEmpty && (
<div className="w-full mt-64 text-2xl text-center text-gray-400">
{intl.formatMessage(messages.noresults)}
{intl.formatMessage(globalMessages.noresults)}
</div>
)}
<ul className="cardList">
<ul className="cards-vertical">
{items?.map((title, index) => {
let titleCard: React.ReactNode;

@ -1,12 +1,12 @@
import React, { MouseEvent, ReactNode, useRef } from 'react';
import ReactDOM from 'react-dom';
import Button, { ButtonType } from '../Button';
import { useLockBodyScroll } from '../../../hooks/useLockBodyScroll';
import LoadingSpinner from '../LoadingSpinner';
import useClickOutside from '../../../hooks/useClickOutside';
import { useIntl } from 'react-intl';
import useClickOutside from '../../../hooks/useClickOutside';
import { useLockBodyScroll } from '../../../hooks/useLockBodyScroll';
import globalMessages from '../../../i18n/globalMessages';
import Transition from '../../Transition';
import Button, { ButtonType } from '../Button';
import LoadingSpinner from '../LoadingSpinner';
interface ModalProps {
title?: string;
@ -98,11 +98,14 @@ const Modal: React.FC<ModalProps> = ({
show={!loading}
>
<div
className="inline-block w-full max-h-full px-4 pt-5 pb-4 overflow-auto text-left align-bottom transition-all transform bg-gray-700 shadow-xl sm:rounded-lg sm:my-8 sm:align-middle sm:max-w-3xl"
className="relative inline-block w-full px-4 pt-5 pb-4 overflow-auto text-left align-bottom transition-all transform bg-gray-700 shadow-xl sm:rounded-lg sm:my-8 sm:align-middle sm:max-w-3xl"
role="dialog"
aria-modal="true"
aria-labelledby="modal-headline"
ref={modalRef}
style={{
maxHeight: 'calc(100% - env(safe-area-inset-top) * 2)',
}}
>
<div className="sm:flex sm:items-center">
{iconSvg && (

@ -0,0 +1,74 @@
import React, { useEffect, useRef } from 'react';
interface ProgressCircleProps {
className?: string;
progress?: number;
useHeatLevel?: boolean;
}
const ProgressCircle: React.FC<ProgressCircleProps> = ({
className,
progress = 0,
useHeatLevel,
}) => {
const ref = useRef<SVGCircleElement>(null);
let color = '';
let emptyColor = 'text-gray-300';
if (useHeatLevel) {
color = 'text-green-500';
if (progress <= 50) {
color = 'text-yellow-500';
}
if (progress <= 10) {
color = 'text-red-500';
}
if (progress === 0) {
emptyColor = 'text-red-600';
}
}
useEffect(() => {
if (ref && ref.current) {
const radius = ref.current?.r.baseVal.value;
const circumference = (radius ?? 0) * 2 * Math.PI;
const offset = circumference - (progress / 100) * circumference;
ref.current.style.strokeDashoffset = `${offset}`;
ref.current.style.strokeDasharray = `${circumference} ${circumference}`;
}
});
return (
<svg className={`${className} ${color}`} viewBox="0 0 24 24">
<circle
className={`${emptyColor} opacity-30`}
stroke="currentColor"
strokeWidth="3"
fill="transparent"
r="10"
cx="12"
cy="12"
/>
<circle
style={{
transition: '0.35s stroke-dashoffset',
transform: 'rotate(-90deg)',
transformOrigin: '50% 50%',
}}
ref={ref}
stroke="currentColor"
strokeWidth="3"
fill="transparent"
r="10"
cx="12"
cy="12"
/>
</svg>
);
};
export default ProgressCircle;

@ -1,8 +1,8 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
import React, { useState, useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
import Transition from '../../Transition';
import { useLockBodyScroll } from '../../../hooks/useLockBodyScroll';
import Transition from '../../Transition';
interface SlideOverProps {
show?: boolean;
@ -70,7 +70,7 @@ const SlideOver: React.FC<SlideOverProps> = ({
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col h-full overflow-y-scroll bg-gray-700 shadow-xl">
<header className="px-4 py-6 space-y-1 bg-indigo-600">
<header className="px-4 space-y-1 bg-indigo-600 slideover">
<div className="flex items-center justify-between space-x-3">
<h2 className="text-lg font-medium leading-7 text-white">
{title}

@ -13,7 +13,7 @@ const CompanyCard: React.FC<CompanyCardProps> = ({ image, url, name }) => {
return (
<Link href={url}>
<a
className={`relative flex items-center justify-center h-32 w-64 sm:h-36 sm:w-72 p-8 shadow transition ease-in-out duration-150 cursor-pointer transform-gpu ring-1 ${
className={`relative flex items-center justify-center h-32 w-56 sm:h-36 sm:w-72 p-8 shadow transition ease-in-out duration-300 cursor-pointer transform-gpu ring-1 ${
isHovered
? 'bg-gray-700 scale-105 ring-gray-500'
: 'bg-gray-800 scale-100 ring-gray-700'

@ -0,0 +1,56 @@
import React, { useContext } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import GenreCard from '../../GenreCard';
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
import { LanguageContext } from '../../../context/LanguageContext';
import { genreColorMap } from '../constants';
import PageTitle from '../../Common/PageTitle';
import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner';
import Error from '../../../pages/_error';
const messages = defineMessages({
moviegenres: 'Movie Genres',
});
const MovieGenreList: React.FC = () => {
const { locale } = useContext(LanguageContext);
const intl = useIntl();
const { data, error } = useSWR<GenreSliderItem[]>(
`/api/v1/discover/genreslider/movie?language=${locale}`
);
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data) {
return <Error statusCode={404} />;
}
return (
<>
<PageTitle title={intl.formatMessage(messages.moviegenres)} />
<div className="mt-1 mb-5">
<Header>{intl.formatMessage(messages.moviegenres)}</Header>
</div>
<ul className="cards-horizontal">
{data.map((genre, index) => (
<li key={`genre-${genre.id}-${index}`}>
<GenreCard
name={genre.name}
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
genreColorMap[genre.id] ?? genreColorMap[0]
})${genre.backdrops[4]}`}
url={`/discover/movies/genre/${genre.id}`}
canExpand
/>
</li>
))}
</ul>
</>
);
};
export default MovieGenreList;

@ -0,0 +1,70 @@
import React, { useContext } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import GenreCard from '../../GenreCard';
import Slider from '../../Slider';
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
import { LanguageContext } from '../../../context/LanguageContext';
import { genreColorMap } from '../constants';
import Link from 'next/link';
const messages = defineMessages({
moviegenres: 'Movie Genres',
});
const MovieGenreSlider: React.FC = () => {
const { locale } = useContext(LanguageContext);
const intl = useIntl();
const { data, error } = useSWR<GenreSliderItem[]>(
`/api/v1/discover/genreslider/movie?language=${locale}`,
{
refreshInterval: 0,
revalidateOnFocus: false,
}
);
return (
<>
<div className="slider-header">
<Link href="/discover/movies/genres">
<a className="slider-title">
<span>{intl.formatMessage(messages.moviegenres)}</span>
<svg
className="w-6 h-6 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</a>
</Link>
</div>
<Slider
sliderKey="movie-genres"
isLoading={!data && !error}
isEmpty={false}
items={(data ?? []).map((genre, index) => (
<GenreCard
key={`genre-${genre.id}-${index}`}
name={genre.name}
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
genreColorMap[genre.id] ?? genreColorMap[0]
})${genre.backdrops[4]}`}
url={`/discover/movies/genre/${genre.id}`}
/>
))}
placeholder={<GenreCard.Placeholder />}
emptyMessage=""
/>
</>
);
};
export default React.memo(MovieGenreSlider);

@ -17,43 +17,49 @@ const networks: Network[] = [
{
name: 'Netflix',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/wwemzKWzjKYJFfCeiB57q3r4Bcm.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/wwemzKWzjKYJFfCeiB57q3r4Bcm.png',
url: '/discover/tv/network/213',
},
{
name: 'Disney+',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/gJ8VX6JSu3ciXHuC2dDGAo2lvwM.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/gJ8VX6JSu3ciXHuC2dDGAo2lvwM.png',
url: '/discover/tv/network/2739',
},
{
name: 'Prime Video',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ifhbNuuVnlwYy5oXA5VIb2YR8AZ.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ifhbNuuVnlwYy5oXA5VIb2YR8AZ.png',
url: '/discover/tv/network/1024',
},
{
name: 'Apple TV+',
image:
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/4KAy34EHvRM25Ih8wb82AuGU7zJ.png',
url: '/discover/tv/network/2552',
},
{
name: 'HBO',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/tuomPhY2UtuPTqqFnKMVHvSb724.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/tuomPhY2UtuPTqqFnKMVHvSb724.png',
url: '/discover/tv/network/49',
},
{
name: 'ABC',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ndAvF4JLsliGreX87jAc9GdjmJY.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ndAvF4JLsliGreX87jAc9GdjmJY.png',
url: '/discover/tv/network/2',
},
{
name: 'FOX',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1DSpHrWyOORkL9N2QHX7Adt31mQ.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1DSpHrWyOORkL9N2QHX7Adt31mQ.png',
url: '/discover/tv/network/19',
},
{
name: 'Cinemax',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/6mSHSquNpfLgDdv6VnOOvC5Uz2h.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/6mSHSquNpfLgDdv6VnOOvC5Uz2h.png',
url: '/discover/tv/network/359',
},
{
@ -65,55 +71,55 @@ const networks: Network[] = [
{
name: 'Showtime',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/Allse9kbjiP6ExaQrnSpIhkurEi.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/Allse9kbjiP6ExaQrnSpIhkurEi.png',
url: '/discover/tv/network/67',
},
{
name: 'Starz',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/8GJjw3HHsAJYwIWKIPBPfqMxlEa.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/8GJjw3HHsAJYwIWKIPBPfqMxlEa.png',
url: '/discover/tv/network/318',
},
{
name: 'The CW',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ge9hzeaU7nMtQ4PjkFlc68dGAJ9.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ge9hzeaU7nMtQ4PjkFlc68dGAJ9.png',
url: '/discover/tv/network/71',
},
{
name: 'NBC',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/o3OedEP0f9mfZr33jz2BfXOUK5.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/o3OedEP0f9mfZr33jz2BfXOUK5.png',
url: '/discover/tv/network/6',
},
{
name: 'CBS',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/nm8d7P7MJNiBLdgIzUK0gkuEA4r.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/nm8d7P7MJNiBLdgIzUK0gkuEA4r.png',
url: '/discover/tv/network/16',
},
{
name: 'BBC One',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/mVn7xESaTNmjBUyUtGNvDQd3CT1.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/mVn7xESaTNmjBUyUtGNvDQd3CT1.png',
url: '/discover/tv/network/4',
},
{
name: 'Cartoon Network',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/c5OC6oVCg6QP4eqzW6XIq17CQjI.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/c5OC6oVCg6QP4eqzW6XIq17CQjI.png',
url: '/discover/tv/network/56',
},
{
name: 'Adult Swim',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/9AKyspxVzywuaMuZ1Bvilu8sXly.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/9AKyspxVzywuaMuZ1Bvilu8sXly.png',
url: '/discover/tv/network/80',
},
{
name: 'Nickelodeon',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ikZXxg6GnwpzqiZbRPhJGaZapqB.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/ikZXxg6GnwpzqiZbRPhJGaZapqB.png',
url: '/discover/tv/network/13',
},
];

@ -23,7 +23,7 @@ const studios: Studio[] = [
{
name: '20th Century Fox',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/qZCc1lty5FzX30aOCVRBLzaVmcp.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/qZCc1lty5FzX30aOCVRBLzaVmcp.png',
url: '/discover/movies/studio/25',
},
{
@ -41,7 +41,7 @@ const studios: Studio[] = [
{
name: 'Universal',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/8lvHyhjr8oUKOOy2dKXoALWKdp0.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/8lvHyhjr8oUKOOy2dKXoALWKdp0.png',
url: '/discover/movies/studio/33',
},
{
@ -53,19 +53,19 @@ const studios: Studio[] = [
{
name: 'Pixar',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1TjvGVDMYsj6JBxOAkUHpPEwLf7.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/1TjvGVDMYsj6JBxOAkUHpPEwLf7.png',
url: '/discover/movies/studio/3',
},
{
name: 'Dreamworks',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/kP7t6RwGz2AvvTkvnI1uteEwHet.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/kP7t6RwGz2AvvTkvnI1uteEwHet.png',
url: '/discover/movies/studio/521',
},
{
name: 'Marvel Studios',
image:
'http://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/hUzeosd33nzE5MCNsZxCGEKTXaQ.png',
'https://image.tmdb.org/t/p/w780_filter(duotone,ffffff,bababa)/hUzeosd33nzE5MCNsZxCGEKTXaQ.png',
url: '/discover/movies/studio/420',
},
{

@ -0,0 +1,56 @@
import React, { useContext } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import GenreCard from '../../GenreCard';
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
import { LanguageContext } from '../../../context/LanguageContext';
import { genreColorMap } from '../constants';
import PageTitle from '../../Common/PageTitle';
import Header from '../../Common/Header';
import LoadingSpinner from '../../Common/LoadingSpinner';
import Error from '../../../pages/_error';
const messages = defineMessages({
seriesgenres: 'Series Genres',
});
const TvGenreList: React.FC = () => {
const { locale } = useContext(LanguageContext);
const intl = useIntl();
const { data, error } = useSWR<GenreSliderItem[]>(
`/api/v1/discover/genreslider/tv?language=${locale}`
);
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data) {
return <Error statusCode={404} />;
}
return (
<>
<PageTitle title={intl.formatMessage(messages.seriesgenres)} />
<div className="mt-1 mb-5">
<Header>{intl.formatMessage(messages.seriesgenres)}</Header>
</div>
<ul className="cards-horizontal">
{data.map((genre, index) => (
<li key={`genre-${genre.id}-${index}`}>
<GenreCard
name={genre.name}
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
genreColorMap[genre.id] ?? genreColorMap[0]
})${genre.backdrops[4]}`}
url={`/discover/tv/genre/${genre.id}`}
canExpand
/>
</li>
))}
</ul>
</>
);
};
export default TvGenreList;

@ -0,0 +1,70 @@
import React, { useContext } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import GenreCard from '../../GenreCard';
import Slider from '../../Slider';
import { GenreSliderItem } from '../../../../server/interfaces/api/discoverInterfaces';
import { LanguageContext } from '../../../context/LanguageContext';
import { genreColorMap } from '../constants';
import Link from 'next/link';
const messages = defineMessages({
tvgenres: 'Series Genres',
});
const TvGenreSlider: React.FC = () => {
const { locale } = useContext(LanguageContext);
const intl = useIntl();
const { data, error } = useSWR<GenreSliderItem[]>(
`/api/v1/discover/genreslider/tv?language=${locale}`,
{
refreshInterval: 0,
revalidateOnFocus: false,
}
);
return (
<>
<div className="slider-header">
<Link href="/discover/tv/genres">
<a className="slider-title">
<span>{intl.formatMessage(messages.tvgenres)}</span>
<svg
className="w-6 h-6 ml-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</a>
</Link>
</div>
<Slider
sliderKey="tv-genres"
isLoading={!data && !error}
isEmpty={false}
items={(data ?? []).map((genre, index) => (
<GenreCard
key={`genre-tv-${genre.id}-${index}`}
name={genre.name}
image={`https://image.tmdb.org/t/p/w1280_filter(duotone,${
genreColorMap[genre.id] ?? genreColorMap[0]
})${genre.backdrops[4]}`}
url={`/discover/tv/genre/${genre.id}`}
/>
))}
placeholder={<GenreCard.Placeholder />}
emptyMessage=""
/>
</>
);
};
export default React.memo(TvGenreSlider);

@ -0,0 +1,63 @@
type AvailableColors =
| 'black'
| 'red'
| 'darkred'
| 'blue'
| 'lightblue'
| 'darkblue'
| 'orange'
| 'darkorange'
| 'green'
| 'lightgreen'
| 'purple'
| 'darkpurple'
| 'yellow'
| 'pink';
export const colorTones: Record<AvailableColors, [string, string]> = {
red: ['991B1B', 'FCA5A5'],
darkred: ['1F2937', 'F87171'],
blue: ['032541', '01b4e4'],
lightblue: ['1F2937', '60A5FA'],
darkblue: ['1F2937', '2864d2'],
orange: ['92400E', 'FCD34D'],
lightgreen: ['065F46', '6EE7B7'],
green: ['087d29', '21cb51'],
purple: ['5B21B6', 'C4B5FD'],
yellow: ['777e0d', 'e4ed55'],
darkorange: ['552c01', 'd47c1d'],
black: ['1F2937', 'D1D5DB'],
pink: ['9D174D', 'F9A8D4'],
darkpurple: ['480c8b', 'a96bef'],
};
export const genreColorMap: Record<number, [string, string]> = {
0: colorTones.black,
28: colorTones.red, // Action
12: colorTones.darkpurple, // Adventure
16: colorTones.blue, // Animation
35: colorTones.orange, // Comedy
80: colorTones.darkblue, // Crime
99: colorTones.lightgreen, // Documentary
18: colorTones.pink, // Drama
10751: colorTones.yellow, // Family
14: colorTones.lightblue, // Fantasy
36: colorTones.orange, // History
27: colorTones.black, // Horror
10402: colorTones.blue, // Music
9648: colorTones.purple, // Mystery
10749: colorTones.pink, // Romance
878: colorTones.lightblue, // Science Fiction
10770: colorTones.red, // TV Movie
53: colorTones.black, // Thriller
10752: colorTones.darkred, // War
37: colorTones.orange, // Western
10759: colorTones.darkpurple, // Action & Adventure
10762: colorTones.blue, // Kids
10763: colorTones.black, // News
10764: colorTones.darkorange, // Reality
10765: colorTones.lightblue, // Sci-Fi & Fantasy
10766: colorTones.pink, // Soap
10767: colorTones.lightgreen, // Talk
10768: colorTones.darkred, // War & Politics
};

@ -1,16 +1,18 @@
import React from 'react';
import useSWR from 'swr';
import TmdbTitleCard from '../TitleCard/TmdbTitleCard';
import Slider from '../Slider';
import Link from 'next/link';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { MediaResultsResponse } from '../../../server/interfaces/api/mediaInterfaces';
import type { RequestResultsResponse } from '../../../server/interfaces/api/requestInterfaces';
import RequestCard from '../RequestCard';
import MediaSlider from '../MediaSlider';
import PageTitle from '../Common/PageTitle';
import StudioSlider from './StudioSlider';
import MediaSlider from '../MediaSlider';
import RequestCard from '../RequestCard';
import Slider from '../Slider';
import TmdbTitleCard from '../TitleCard/TmdbTitleCard';
import MovieGenreSlider from './MovieGenreSlider';
import NetworkSlider from './NetworkSlider';
import StudioSlider from './StudioSlider';
import TvGenreSlider from './TvGenreSlider';
const messages = defineMessages({
discover: 'Discover',
@ -28,14 +30,16 @@ const Discover: React.FC = () => {
const intl = useIntl();
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded'
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded',
{ revalidateOnMount: true }
);
const {
data: requests,
error: requestError,
} = useSWR<RequestResultsResponse>(
'/api/v1/request?filter=unavailable&take=10&sort=modified&skip=0'
'/api/v1/request?filter=all&take=10&sort=modified&skip=0',
{ revalidateOnMount: true }
);
return (
@ -59,7 +63,7 @@ const Discover: React.FC = () => {
))}
/>
<div className="slider-header">
<Link href="/requests">
<Link href="/requests?filter=all">
<a className="slider-title">
<span>{intl.formatMessage(messages.recentrequests)}</span>
<svg
@ -104,6 +108,7 @@ const Discover: React.FC = () => {
url="/api/v1/discover/movies"
linkUrl="/discover/movies"
/>
<MovieGenreSlider />
<MediaSlider
sliderKey="upcoming"
title={intl.formatMessage(messages.upcoming)}
@ -117,6 +122,7 @@ const Discover: React.FC = () => {
url="/api/v1/discover/tv"
linkUrl="/discover/tv"
/>
<TvGenreSlider />
<MediaSlider
sliderKey="upcoming-tv"
title={intl.formatMessage(messages.upcomingtv)}

@ -64,6 +64,7 @@ const DownloadBlock: React.FC<DownloadBlockProps> = ({
1000
)}
updateIntervalInSeconds={1}
numeric="auto"
/>
) : (
'N/A'

@ -1,10 +1,11 @@
import React from 'react';
import TmdbLogo from '../../assets/services/tmdb.svg';
import TvdbLogo from '../../assets/services/tvdb.svg';
import React, { useContext } from 'react';
import { MediaType } from '../../../server/constants/media';
import ImdbLogo from '../../assets/services/imdb.svg';
import RTLogo from '../../assets/services/rt.svg';
import PlexLogo from '../../assets/services/plex.svg';
import { MediaType } from '../../../server/constants/media';
import RTLogo from '../../assets/services/rt.svg';
import TmdbLogo from '../../assets/services/tmdb.svg';
import TvdbLogo from '../../assets/services/tvdb.svg';
import { LanguageContext } from '../../context/LanguageContext';
interface ExternalLinkBlockProps {
mediaType: 'movie' | 'tv';
@ -23,6 +24,8 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
rtUrl,
plexUrl,
}) => {
const { locale } = useContext(LanguageContext);
return (
<div className="flex items-center justify-center w-full space-x-5">
{plexUrl && (
@ -37,7 +40,7 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
)}
{tmdbId && (
<a
href={`https://www.themoviedb.org/${mediaType}/${tmdbId}`}
href={`https://www.themoviedb.org/${mediaType}/${tmdbId}?language=${locale}`}
className="w-8 transition duration-300 opacity-50 hover:opacity-100"
target="_blank"
rel="noreferrer"

@ -0,0 +1,65 @@
import Link from 'next/link';
import React, { useState } from 'react';
import { withProperties } from '../../utils/typeHelpers';
import CachedImage from '../Common/CachedImage';
interface GenreCardProps {
name: string;
image: string;
url: string;
canExpand?: boolean;
}
const GenreCard: React.FC<GenreCardProps> = ({
image,
url,
name,
canExpand = false,
}) => {
const [isHovered, setHovered] = useState(false);
return (
<Link href={url}>
<a
className={`relative flex items-center justify-center h-32 sm:h-36 ${
canExpand ? 'w-full' : 'w-56 sm:w-72'
} p-8 shadow transition ease-in-out duration-300 cursor-pointer transform-gpu ring-1 ${
isHovered
? 'bg-gray-700 scale-105 ring-gray-500 bg-opacity-100'
: 'bg-gray-800 scale-100 ring-gray-700 bg-opacity-80'
} rounded-xl bg-cover bg-center overflow-hidden`}
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => setHovered(false)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setHovered(true);
}
}}
role="link"
tabIndex={0}
>
<CachedImage src={image} alt="" layout="fill" objectFit="cover" />
<div
className={`absolute z-10 inset-0 w-full h-full transition duration-300 bg-gray-800 ${
isHovered ? 'bg-opacity-10' : 'bg-opacity-30'
}`}
/>
<div className="relative z-20 w-full text-2xl font-bold text-center text-white truncate whitespace-normal sm:text-3xl">
{name}
</div>
</a>
</Link>
);
};
const GenreCardPlaceholder: React.FC = () => {
return (
<div
className={`relative h-32 w-56 sm:h-40 sm:w-72 animate-pulse rounded-xl bg-gray-700`}
></div>
);
};
export default withProperties(GenreCard, { Placeholder: GenreCardPlaceholder });

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save