Merge branch 'develop'

pull/864/head
sct 4 years ago
commit c80a1fa261

@ -264,6 +264,34 @@
"code",
"translation"
]
},
{
"login": "chriscpritchard",
"name": "Chris Pritchard",
"avatar_url": "https://avatars1.githubusercontent.com/u/1839074?v=4",
"profile": "https://github.com/chriscpritchard",
"contributions": [
"code",
"doc"
]
},
{
"login": "Tamberlox",
"name": "Tamberlox",
"avatar_url": "https://avatars3.githubusercontent.com/u/56069014?v=4",
"profile": "https://github.com/Tamberlox",
"contributions": [
"translation"
]
},
{
"login": "hmnd",
"name": "David",
"avatar_url": "https://avatars.githubusercontent.com/u/12853597?v=4",
"profile": "https://hmnd.io",
"contributions": [
"code"
]
}
],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",

@ -32,6 +32,7 @@ module.exports = {
'formatjs/no-offset': 'error',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': ['error'],
'jsx-a11y/no-onchange': 'off',
},
overrides: [
{

@ -56,6 +56,10 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Switch to master branch
run: git checkout master
- name: Prepare
id: prepare

3
.gitignore vendored

@ -47,3 +47,6 @@ dist
# sqlite journal
config/db/db.sqlite3-journal
# VS Code
.vscode/launch.json

@ -19,6 +19,9 @@
"stylelint.vscode-stylelint",
"bradlc.vscode-tailwindcss"
"bradlc.vscode-tailwindcss",
// https://marketplace.visualstudio.com/items?itemName=heybourn.headwind
"heybourn.headwind"
]
}

@ -16,7 +16,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>
<img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr">
<!-- 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-28-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-31-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
</p>
@ -46,7 +46,7 @@
## Getting Started
Check out our documenation for steps on how to install and run Overseerr:
Check out our documentation for steps on how to install and run Overseerr:
https://docs.overseerr.dev/getting-started/installation
@ -58,6 +58,7 @@ Currently, Overseerr is only distributed through Docker images. If you have Dock
docker run -d \
-e LOG_LEVEL=info \
-e TZ=Asia/Tokyo \
-e PROXY=<yes|no>
-p 5055:5055 \
-v /path/to/appdata/config:/app/config \
--restart unless-stopped \
@ -139,6 +140,11 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center"><a href="https://github.com/hirenshah"><img src="https://avatars2.githubusercontent.com/u/418112?v=4?s=100" width="100px;" alt=""/><br /><sub><b>hirenshah</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hirenshah" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/TheCatLady"><img src="https://avatars0.githubusercontent.com/u/52870424?v=4?s=100" width="100px;" alt=""/><br /><sub><b>TheCatLady</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=TheCatLady" title="Code">💻</a> <a href="#translation-TheCatLady" title="Translation">🌍</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/chriscpritchard"><img src="https://avatars1.githubusercontent.com/u/1839074?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Chris Pritchard</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=chriscpritchard" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/Tamberlox"><img src="https://avatars3.githubusercontent.com/u/56069014?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Tamberlox</b></sub></a><br /><a href="#translation-Tamberlox" title="Translation">🌍</a></td>
<td align="center"><a href="https://hmnd.io"><img src="https://avatars.githubusercontent.com/u/12853597?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=hmnd" title="Code">💻</a></td>
</tr>
</table>
<!-- markdownlint-restore -->

@ -18,4 +18,5 @@
## Extending Overseerr
- [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md)
* [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md)
* [Fail2ban Filter](extending-overseerr/fail2ban.md)

@ -0,0 +1,14 @@
# Fail2ban Filter
{% hint style="warning" %}
If you are running Overseerr behind a reverse proxy, make sure that the **Enable Proxy Support** setting is **enabled**.
{% endhint %}
To use Fail2ban with Overseerr, create a new file named `overseerr.local` in your Fail2ban `filter.d` directory with the following filter definition:
```
[Definition]
failregex = .*\[info\]\[Auth\]\: Failed login attempt.*"ip":"<HOST>"
```
You can then add a jail using this filter in `jail.local`. Please see the [Fail2ban documetation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8#Jails) for details on how to configure the jail.

@ -122,6 +122,8 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always;
# Prevent Sniff Mimetype (X-Content-Type-Options)
add_header X-Content-Type-Options "nosniff" always;
# Tell crawling bots to not index the site
add_header X-Robots-Tag "noindex, nofollow" always;
access_log /var/log/nginx/overseerr.example.com-access.log;
error_log /var/log/nginx/overseerr.example.com-error.log;
@ -131,4 +133,3 @@ server {
}
}
```

@ -121,26 +121,49 @@ This version can break any moment. Be prepared to troubleshoot any issues that a
{% tabs %}
{% tab title="Gentoo" %}
Portage overlay [GitHub Repository](https://github.com/chriscpritchard/overseerr-overlay)
Portage overlay [GitHub Repository](https://github.com/chriscpritchard/overseerr-overlay).
This is now included in the list of [Gentoo repositories](https://overlays.gentoo.org/), so can be easily enabled with `eselect repository`
Efforts will be made to keep up to date with the latest releases, however, this cannot be guaranteed.
To enable using eselect repository, run:
**To enable:**
To enable using `eselect repository`, run:
```bash
eselect repository add overseerr-overlay git https://github.com/chriscpritchard/overseerr-overlay.git
eselect repository enable overseerr-overlay
```
**To install:**
Once complete, you can just run:
```bash
emerge www-apps/overseerr
```
**To install the development build:**
A live ebuild (`=www-apps/overseerr-9999`) is also available. To use this, you will need to modify accept_keywords for this package:
```bash
emerge --autounmask --autounmask-write "=www-apps/overseerr-9999"
```
Once installed, you will not be notified of updates, so you can update with:
```bash
emerge @live-rebuild
```
or use `app-portage/smart-live-rebuild`
{% hint style="danger" %}
This version can break any moment. Be prepared to troubleshoot any issues that arise!
{% endhint %}
{% endtab %}
{% tab title="Swizzin" %}
The installation is not implemented via docker, but barebones. The latest released version of overseerr will be used.
The installation is not implemented via Docker, but barebones. The latest release version of Overseerr will be used.
Please see the [swizzin documentation](https://swizzin.ltd/applications/overseerr) for more information.
To install, run the following:

@ -36,7 +36,7 @@ The most secure method, but also the most inconvenient, is to set up a VPN tunne
### Some media is missing from Overseerr that I know is in Plex!
**A:** Overseerr supports the new Plex Movie, Legacy Plex Movie, TheTVDB agent, and the TMDb agent. Please verify that your library is using one of the agents previously listed. If you are changing agents, a full metadata refresh will need to be performed. Caution, this can take a long time depending on how many items you have in your movie library.
**A:** Overseerr supports the new Plex Movie, legacy Plex Movie, TheTVDB, and TMDb agents. Please verify that your library is using one of the agents previously listed. If you are changing agents, a full metadata refresh will need to be performed. Caution, this can take a long time depending on how many items you have in your movie library.
**Troubleshooting Steps:**
@ -55,8 +55,8 @@ Perform these steps to verify the media item has a guid Overseerr can match.
**Examples:**
1. TMDB agent `guid="com.plexapp.agents.themoviedb://1705"`
2. The new Plex Movie agent `<Guid id="tmdb://464052"/>`
1. TMDb agent `guid="com.plexapp.agents.themoviedb://1705"`
2. New Plex Movie agent `<Guid id="tmdb://464052"/>`
3. TheTVDB agent `guid="com.plexapp.agents.thetvdb://78874/1/1"`
4. Legacy Plex Movie agent `guid="com.plexapp.agents.imdb://tt0765446"`
@ -68,7 +68,7 @@ Perform these steps to verify the media item has a guid Overseerr can match.
### Why can't I see all my Plex users?
**A:** Navigate to your **User List** in Overseerr and click **Import Users From Plex** button. Don't forget to check the default user permissions in the **Settings -&gt; General Settings** page beforehand.
**A:** Navigate to your **User List** in Overseerr and click **Import Users from Plex** button. Don't forget to check the default user permissions in the **Settings -&gt; General Settings** page beforehand.
### Can I create local users in Overseerr?

@ -11,7 +11,7 @@ Overseerr already supports a good number of notification agents, such as **Disco
- Pushover
- [Webhooks](./webhooks.md)
## Setting up Notifications
## Setting Up Notifications
Configuring your notifications is _very simple_. First, you will need to visit the **Settings** page and click **Notifications** in the menu. This will present you with all of the currently available notification agents. Click on each one individually to configure them.

@ -42,8 +42,8 @@ These variables are usually the target user of the notification.
These variables are only included in media related notifications, such as requests.
- `{{media_type}}` Media type. Either `movie` or `tv`.
- `{{media_tmdbid}}` Media's TMDB ID.
- `{{media_imdbid}}` Media's IMDB ID.
- `{{media_tmdbid}}` Media's TMDb ID.
- `{{media_imdbid}}` Media's IMDb ID.
- `{{media_tvdbid}}` Media's TVDB ID.
- `{{media_status}}` Media's availability status. (Ex. `AVAILABLE` or `PENDING`)
- `{{media_status4k}}` Media's 4K availability status. (Ex. `AVAILABLE` or `PENDING`)

File diff suppressed because it is too large Load Diff

@ -17,6 +17,7 @@
},
"license": "MIT",
"dependencies": {
"@supercharge/request-ip": "^1.1.2",
"@svgr/webpack": "^5.5.0",
"ace-builds": "^1.4.12",
"axios": "^0.21.1",
@ -25,6 +26,7 @@
"bowser": "^2.11.0",
"connect-typeorm": "^1.1.4",
"cookie-parser": "^1.4.5",
"csurf": "^1.11.0",
"email-templates": "^8.0.3",
"express": "^4.17.1",
"express-openapi-validator": "^4.10.8",
@ -36,11 +38,12 @@
"next": "10.0.3",
"node-schedule": "^1.3.2",
"nodemailer": "^6.4.17",
"nookies": "^2.5.1",
"nookies": "^2.5.2",
"plex-api": "^5.3.1",
"pug": "^3.0.0",
"react": "17.0.1",
"react-ace": "^9.2.1",
"react-animate-height": "^2.0.23",
"react-dom": "17.0.1",
"react-intersection-observer": "^8.31.0",
"react-intl": "^5.10.16",
@ -77,11 +80,12 @@
"@types/bcrypt": "^3.0.0",
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/csurf": "^1.11.0",
"@types/email-templates": "^8.0.0",
"@types/express": "^4.17.11",
"@types/express-session": "^1.17.0",
"@types/lodash": "^4.14.167",
"@types/node": "^14.14.21",
"@types/lodash": "^4.14.168",
"@types/node": "^14.14.22",
"@types/node-schedule": "^1.3.1",
"@types/nodemailer": "^6.4.0",
"@types/react": "^17.0.0",
@ -91,11 +95,11 @@
"@types/secure-random-password": "^0.2.0",
"@types/swagger-ui-express": "^4.1.2",
"@types/uuid": "^8.3.0",
"@types/xml2js": "^0.4.7",
"@types/xml2js": "^0.4.8",
"@types/yamljs": "^0.2.31",
"@types/yup": "^0.29.11",
"@typescript-eslint/eslint-plugin": "^4.13.0",
"@typescript-eslint/parser": "^4.13.0",
"@typescript-eslint/eslint-plugin": "^4.14.0",
"@typescript-eslint/parser": "^4.14.0",
"autoprefixer": "^9",
"babel-plugin-react-intl": "^8.2.25",
"babel-plugin-react-intl-auto": "^3.3.0",
@ -103,7 +107,7 @@
"copyfiles": "^2.4.1",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^7.18.0",
"eslint-config-prettier": "^7.1.0",
"eslint-config-prettier": "^7.2.0",
"eslint-plugin-formatjs": "^2.10.3",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-prettier": "^3.3.1",
@ -116,12 +120,15 @@
"postcss": "^7",
"postcss-preset-env": "^6.7.0",
"prettier": "^2.2.1",
"semantic-release": "^17.3.3",
"semantic-release": "^17.3.6",
"semantic-release-docker": "^2.2.0",
"tailwindcss": "npm:@tailwindcss/postcss7-compat",
"ts-node": "^9.1.1",
"typescript": "^4.1.3"
},
"resolutions": {
"sqlite3/node-gyp": "^5.1.0"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

@ -12,7 +12,7 @@ const LOCAL_PATH = path.join(__dirname, '../../config/anime-list.xml');
const mappingRegexp = new RegExp(/;[0-9]+-([0-9]+)/g);
// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to tvdb/tmdb IDs
// Anime-List xml files are community maintained mappings that Hama agent uses to map AniDB IDs to TVDB/TMDb IDs
// https://github.com/Anime-Lists/anime-lists/
interface AnimeMapping {
@ -125,7 +125,7 @@ class AnimeListMapping {
}
} else {
// some movies do not have mapping-list, so map episode 1,2,3,..to movies
// movies must have imdbid or tmdbid
// movies must have imdbId or tmdbId
const hasImdb = imdbIds.length > 1 || imdbIds[0] !== undefined;
if ((hasImdb || tmdbId) && anime.$.defaulttvdbseason === '0') {
if (!this.specials[tvdbId]) {

@ -1,5 +1,5 @@
import NodePlexAPI from 'plex-api';
import { getSettings } from '../lib/settings';
import { getSettings, PlexSettings } from '../lib/settings';
export interface PlexLibraryItem {
ratingKey: string;
@ -80,13 +80,26 @@ interface PlexMetadataResponse {
class PlexAPI {
private plexClient: NodePlexAPI;
constructor({ plexToken }: { plexToken?: string }) {
constructor({
plexToken,
plexSettings,
timeout,
}: {
plexToken?: string;
plexSettings?: PlexSettings;
timeout?: number;
}) {
const settings = getSettings();
let settingsPlex: PlexSettings | undefined;
plexSettings
? (settingsPlex = plexSettings)
: (settingsPlex = getSettings().plex);
this.plexClient = new NodePlexAPI({
hostname: settings.plex.ip,
port: settings.plex.port,
https: settings.plex.useSsl,
hostname: settingsPlex.ip,
port: settingsPlex.port,
https: settingsPlex.useSsl,
timeout: timeout,
token: plexToken,
authenticator: {
authenticate: (
@ -111,6 +124,7 @@ class PlexAPI {
});
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
public async getStatus() {
return await this.plexClient.query('/');
}

@ -1,5 +1,6 @@
import axios, { AxiosInstance } from 'axios';
import xml2js from 'xml2js';
import { PlexDevice } from '../interfaces/api/plexInterfaces';
import { getSettings } from '../lib/settings';
import logger from '../logger';
@ -29,6 +30,45 @@ interface PlexUser {
entitlements: string[];
}
interface ConnectionResponse {
$: {
protocol: string;
address: string;
port: string;
uri: string;
local: string;
};
}
interface DeviceResponse {
$: {
name: string;
product: string;
productVersion: string;
platform: string;
platformVersion: string;
device: string;
clientIdentifier: string;
createdAt: string;
lastSeenAt: string;
provides: string;
owned: string;
accessToken?: string;
publicAddress?: string;
httpsRequired?: string;
synced?: string;
relay?: string;
dnsRebindingProtection?: string;
natLoopbackSupported?: string;
publicAddressMatches?: string;
presence?: string;
ownerID?: string;
home?: string;
sourceTitle?: string;
};
Connection: ConnectionResponse[];
}
interface ServerResponse {
$: {
id: string;
@ -87,6 +127,62 @@ class PlexTvAPI {
});
}
public async getDevices(): Promise<PlexDevice[]> {
try {
const devicesResp = await this.axios.get(
'/api/resources?includeHttps=1',
{
transformResponse: [],
responseType: 'text',
}
);
const parsedXml = await xml2js.parseStringPromise(
devicesResp.data as DeviceResponse
);
return parsedXml?.MediaContainer?.Device?.map((pxml: DeviceResponse) => ({
name: pxml.$.name,
product: pxml.$.product,
productVersion: pxml.$.productVersion,
platform: pxml.$?.platform,
platformVersion: pxml.$?.platformVersion,
device: pxml.$?.device,
clientIdentifier: pxml.$.clientIdentifier,
createdAt: new Date(parseInt(pxml.$?.createdAt, 10) * 1000),
lastSeenAt: new Date(parseInt(pxml.$?.lastSeenAt, 10) * 1000),
provides: pxml.$.provides.split(','),
owned: pxml.$.owned == '1' ? true : false,
accessToken: pxml.$?.accessToken,
publicAddress: pxml.$?.publicAddress,
publicAddressMatches:
pxml.$?.publicAddressMatches == '1' ? true : false,
httpsRequired: pxml.$?.httpsRequired == '1' ? true : false,
synced: pxml.$?.synced == '1' ? true : false,
relay: pxml.$?.relay == '1' ? true : false,
dnsRebindingProtection:
pxml.$?.dnsRebindingProtection == '1' ? true : false,
natLoopbackSupported:
pxml.$?.natLoopbackSupported == '1' ? true : false,
presence: pxml.$?.presence == '1' ? true : false,
ownerID: pxml.$?.ownerID,
home: pxml.$?.home == '1' ? true : false,
sourceTitle: pxml.$?.sourceTitle,
connection: pxml?.Connection?.map((conn: ConnectionResponse) => ({
protocol: conn.$.protocol,
address: conn.$.address,
port: parseInt(conn.$.port, 10),
uri: conn.$.uri,
local: conn.$.local == '1' ? true : false,
})),
}));
} catch (e) {
logger.error('Something went wrong getting the devices from plex.tv', {
label: 'Plex.tv API',
errorMessage: e.message,
});
throw new Error('Invalid auth token');
}
}
public async getUser(): Promise<PlexUser> {
try {
const account = await this.axios.get<PlexAccountResponse>(
@ -96,7 +192,7 @@ class PlexTvAPI {
return account.data.user;
} catch (e) {
logger.error(
`Something went wrong getting the account from plex.tv: ${e.message}`,
`Something went wrong while getting the account from plex.tv: ${e.message}`,
{ label: 'Plex.tv API' }
);
throw new Error('Invalid auth token');

@ -1,4 +1,5 @@
import Axios, { AxiosInstance } from 'axios';
import { RadarrSettings } from '../lib/settings';
import logger from '../logger';
interface RadarrMovieOptions {
@ -13,12 +14,13 @@ interface RadarrMovieOptions {
searchNow?: boolean;
}
interface RadarrMovie {
export interface RadarrMovie {
id: number;
title: string;
isAvailable: boolean;
monitored: boolean;
tmdbId: number;
imdbId: string;
titleSlug: string;
folderName: string;
path: string;
@ -45,7 +47,39 @@ export interface RadarrProfile {
name: string;
}
interface QueueItem {
movieId: number;
size: number;
title: string;
sizeleft: number;
timeleft: string;
estimatedCompletionTime: string;
status: string;
trackedDownloadStatus: string;
trackedDownloadState: string;
downloadId: string;
protocol: string;
downloadClient: string;
indexer: string;
id: number;
}
interface QueueResponse {
page: number;
pageSize: number;
sortKey: string;
sortDirection: string;
totalRecords: number;
records: QueueItem[];
}
class RadarrAPI {
static buildRadarrUrl(radarrSettings: RadarrSettings, path?: string): string {
return `${radarrSettings.useSsl ? 'https' : 'http'}://${
radarrSettings.hostname
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}${path}`;
}
private axios: AxiosInstance;
constructor({ url, apiKey }: { url: string; apiKey: string }) {
this.axios = Axios.create({
@ -76,8 +110,89 @@ class RadarrAPI {
}
};
public addMovie = async (options: RadarrMovieOptions): Promise<boolean> => {
public async getMovieByTmdbId(id: number): Promise<RadarrMovie> {
try {
const response = await this.axios.get<RadarrMovie[]>('/movie/lookup', {
params: {
term: `tmdb:${id}`,
},
});
if (!response.data[0]) {
throw new Error('Movie not found');
}
return response.data[0];
} catch (e) {
logger.error('Error retrieving movie by TMDb ID', {
label: 'Radarr API',
message: e.message,
});
throw new Error('Movie not found');
}
}
public addMovie = async (
options: RadarrMovieOptions
): Promise<RadarrMovie> => {
try {
const movie = await this.getMovieByTmdbId(options.tmdbId);
if (movie.downloaded) {
logger.info(
'Title already exists and is available. Skipping add and returning success',
{
label: 'Radarr',
}
);
return movie;
}
// movie exists in radarr but is neither downloaded nor monitored
if (movie.id && !movie.monitored) {
const response = await this.axios.put<RadarrMovie>(`/movie`, {
...movie,
title: options.title,
qualityProfileId: options.qualityProfileId,
profileId: options.profileId,
titleSlug: options.tmdbId.toString(),
minimumAvailability: options.minimumAvailability,
tmdbId: options.tmdbId,
year: options.year,
rootFolderPath: options.rootFolderPath,
monitored: options.monitored,
addOptions: {
searchForMovie: options.searchNow,
},
});
if (response.data.monitored) {
logger.info(
'Found existing title in Radarr and set it to monitored. Returning success',
{ label: 'Radarr' }
);
logger.debug('Radarr update details', {
label: 'Radarr',
movie: response.data,
});
return response.data;
} else {
logger.error('Failed to update existing movie in Radarr', {
label: 'Radarr',
options,
});
throw new Error('Failed to update existing movie in Radarr');
}
}
if (movie.id) {
logger.info(
'Movie is already monitored in Radarr. Skipping add and returning success',
{ label: 'Radarr' }
);
return movie;
}
const response = await this.axios.post<RadarrMovie>(`/movie`, {
title: options.title,
qualityProfileId: options.qualityProfileId,
@ -104,9 +219,9 @@ class RadarrAPI {
label: 'Radarr',
options,
});
return false;
throw new Error('Failed to add movie to Radarr');
}
return true;
return response.data;
} catch (e) {
logger.error(
'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',
@ -117,10 +232,7 @@ class RadarrAPI {
response: e?.response?.data,
}
);
if (e?.response?.data?.[0]?.errorCode === 'MovieExistsValidator') {
return true;
}
return false;
throw new Error('Failed to add movie to Radarr');
}
};
@ -143,6 +255,16 @@ class RadarrAPI {
throw new Error(`[Radarr] Failed to retrieve root folders: ${e.message}`);
}
};
public getQueue = async (): Promise<QueueItem[]> => {
try {
const response = await this.axios.get<QueueResponse>(`/queue`);
return response.data.records;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`);
}
};
}
export default RadarrAPI;

@ -1,9 +1,18 @@
import Axios, { AxiosInstance } from 'axios';
import { SonarrSettings } from '../lib/settings';
import logger from '../logger';
interface SonarrSeason {
seasonNumber: number;
monitored: boolean;
statistics?: {
previousAiring?: string;
episodeFileCount: number;
episodeCount: number;
totalEpisodeCount: number;
sizeOnDisk: number;
percentOfEpisodes: number;
};
}
export interface SonarrSeries {
@ -55,6 +64,33 @@ export interface SonarrSeries {
};
}
interface QueueItem {
seriesId: number;
episodeId: number;
size: number;
title: string;
sizeleft: number;
timeleft: string;
estimatedCompletionTime: string;
status: string;
trackedDownloadStatus: string;
trackedDownloadState: string;
downloadId: string;
protocol: string;
downloadClient: string;
indexer: string;
id: number;
}
interface QueueResponse {
page: number;
pageSize: number;
sortKey: string;
sortDirection: string;
totalRecords: number;
records: QueueItem[];
}
interface SonarrProfile {
id: number;
name: string;
@ -84,6 +120,12 @@ interface AddSeriesOptions {
}
class SonarrAPI {
static buildSonarrUrl(sonarrSettings: SonarrSettings, path?: string): string {
return `${sonarrSettings.useSsl ? 'https' : 'http'}://${
sonarrSettings.hostname
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}${path}`;
}
private axios: AxiosInstance;
constructor({ url, apiKey }: { url: string; apiKey: string }) {
this.axios = Axios.create({
@ -94,6 +136,38 @@ class SonarrAPI {
});
}
public async getSeries(): Promise<SonarrSeries[]> {
try {
const response = await this.axios.get<SonarrSeries[]>('/series');
return response.data;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve series: ${e.message}`);
}
}
public async getSeriesByTitle(title: string): Promise<SonarrSeries[]> {
try {
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
params: {
term: title,
},
});
if (!response.data[0]) {
throw new Error('No series found');
}
return response.data;
} catch (e) {
logger.error('Error retrieving series by series title', {
label: 'Sonarr API',
message: e.message,
});
throw new Error('No series found');
}
}
public async getSeriesByTvdbId(id: number): Promise<SonarrSeries> {
try {
const response = await this.axios.get<SonarrSeries[]>('/series/lookup', {
@ -116,7 +190,7 @@ class SonarrAPI {
}
}
public async addSeries(options: AddSeriesOptions): Promise<boolean> {
public async addSeries(options: AddSeriesOptions): Promise<SonarrSeries> {
try {
const series = await this.getSeriesByTvdbId(options.tvdbid);
@ -138,19 +212,19 @@ class SonarrAPI {
logger.info('Sonarr accepted request. Updated existing series', {
label: 'Sonarr',
});
logger.debug('Sonarr add details', {
logger.debug('Sonarr update details', {
label: 'Sonarr',
movie: newSeriesResponse.data,
});
} else {
logger.error('Failed to add movie to Sonarr', {
logger.error('Failed to update series in Sonarr', {
label: 'Sonarr',
options,
});
return false;
throw new Error('Failed to update series in Sonarr');
}
return true;
return newSeriesResponse.data;
}
const createdSeriesResponse = await this.axios.post<SonarrSeries>(
@ -189,18 +263,18 @@ class SonarrAPI {
label: 'Sonarr',
options,
});
return false;
throw new Error('Failed to add series to Sonarr');
}
return true;
return createdSeriesResponse.data;
} catch (e) {
logger.error('Something went wrong adding a series to Sonarr', {
logger.error('Something went wrong while adding a series to Sonarr.', {
label: 'Sonarr API',
errorMessage: e.message,
error: e,
response: e?.response?.data,
});
return false;
throw new Error('Failed to add series');
}
}
@ -210,7 +284,7 @@ class SonarrAPI {
return response.data;
} catch (e) {
logger.error('Something went wrong retrieving Sonarr profiles', {
logger.error('Something went wrong while retrieving Sonarr profiles.', {
label: 'Sonarr API',
message: e.message,
});
@ -224,10 +298,14 @@ class SonarrAPI {
return response.data;
} catch (e) {
logger.error('Something went wrong retrieving Sonarr root folders', {
logger.error(
'Something went wrong while retrieving Sonarr root folders.',
{
label: 'Sonarr API',
message: e.message,
});
}
);
throw new Error('Failed to get root folders');
}
}
@ -256,6 +334,16 @@ class SonarrAPI {
return newSeasons;
}
public getQueue = async (): Promise<QueueItem[]> => {
try {
const response = await this.axios.get<QueueResponse>(`/queue`);
return response.data.records;
} catch (e) {
throw new Error(`[Radarr] Failed to retrieve queue: ${e.message}`);
}
};
}
export default SonarrAPI;

@ -898,11 +898,11 @@ class TheMovieDb {
}
throw new Error(
`[TMDB] Failed to find a tv show with the provided TVDB id: ${tvdbId}`
`[TMDB] Failed to find a TV show with the provided TVDB ID: ${tvdbId}`
);
} catch (e) {
throw new Error(
`[TMDB] Failed to get tv show by external tvdb ID: ${e.message}`
`[TMDB] Failed to get TV show using the external TVDB ID: ${e.message}`
);
}
}

@ -8,11 +8,16 @@ import {
UpdateDateColumn,
getRepository,
In,
AfterLoad,
} from 'typeorm';
import { MediaRequest } from './MediaRequest';
import { MediaStatus, MediaType } from '../constants/media';
import logger from '../logger';
import Season from './Season';
import { getSettings } from '../lib/settings';
import RadarrAPI from '../api/radarr';
import downloadTracker, { DownloadingItem } from '../lib/downloadtracker';
import SonarrAPI from '../api/sonarr';
@Entity()
class Media {
@ -104,9 +109,170 @@ class Media {
@Column({ type: 'datetime', nullable: true })
public mediaAddedAt: Date;
@Column({ nullable: true })
public serviceId?: number;
@Column({ nullable: true })
public serviceId4k?: number;
@Column({ nullable: true })
public externalServiceId?: number;
@Column({ nullable: true })
public externalServiceId4k?: number;
@Column({ nullable: true })
public externalServiceSlug?: string;
@Column({ nullable: true })
public externalServiceSlug4k?: string;
@Column({ nullable: true })
public ratingKey?: string;
@Column({ nullable: true })
public ratingKey4k?: string;
public serviceUrl?: string;
public serviceUrl4k?: string;
public downloadStatus?: DownloadingItem[] = [];
public downloadStatus4k?: DownloadingItem[] = [];
public plexUrl?: string;
public plexUrl4k?: string;
constructor(init?: Partial<Media>) {
Object.assign(this, init);
}
@AfterLoad()
public setPlexUrls(): void {
const machineId = getSettings().plex.machineId;
if (this.ratingKey) {
this.plexUrl = `https://app.plex.tv/desktop#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${this.ratingKey}`;
}
if (this.ratingKey4k) {
this.plexUrl4k = `https://app.plex.tv/desktop#!/server/${machineId}/details?key=%2Flibrary%2Fmetadata%2F${this.ratingKey4k}`;
}
}
@AfterLoad()
public setServiceUrl(): void {
if (this.mediaType === MediaType.MOVIE) {
if (this.serviceId !== null && this.externalServiceSlug !== null) {
const settings = getSettings();
const server = settings.radarr.find(
(radarr) => radarr.id === this.serviceId
);
if (server) {
this.serviceUrl = server.externalUrl
? `${server.externalUrl}/movie/${this.externalServiceSlug}`
: RadarrAPI.buildRadarrUrl(
server,
`/movie/${this.externalServiceSlug}`
);
}
}
if (this.serviceId4k !== null && this.externalServiceSlug4k !== null) {
const settings = getSettings();
const server = settings.radarr.find(
(radarr) => radarr.id === this.serviceId4k
);
if (server) {
this.serviceUrl4k = server.externalUrl
? `${server.externalUrl}/movie/${this.externalServiceSlug4k}`
: RadarrAPI.buildRadarrUrl(
server,
`/movie/${this.externalServiceSlug4k}`
);
}
}
}
if (this.mediaType === MediaType.TV) {
if (this.serviceId !== null && this.externalServiceSlug !== null) {
const settings = getSettings();
const server = settings.sonarr.find(
(sonarr) => sonarr.id === this.serviceId
);
if (server) {
this.serviceUrl = server.externalUrl
? `${server.externalUrl}/series/${this.externalServiceSlug}`
: SonarrAPI.buildSonarrUrl(
server,
`/series/${this.externalServiceSlug}`
);
}
}
if (this.serviceId4k !== null && this.externalServiceSlug4k !== null) {
const settings = getSettings();
const server = settings.sonarr.find(
(sonarr) => sonarr.id === this.serviceId4k
);
if (server) {
this.serviceUrl4k = server.externalUrl
? `${server.externalUrl}/series/${this.externalServiceSlug4k}`
: SonarrAPI.buildSonarrUrl(
server,
`/series/${this.externalServiceSlug4k}`
);
}
}
}
}
@AfterLoad()
public getDownloadingItem(): void {
if (this.mediaType === MediaType.MOVIE) {
if (
this.externalServiceId !== undefined &&
this.serviceId !== undefined
) {
this.downloadStatus = downloadTracker.getMovieProgress(
this.serviceId,
this.externalServiceId
);
}
if (
this.externalServiceId4k !== undefined &&
this.serviceId4k !== undefined
) {
this.downloadStatus4k = downloadTracker.getMovieProgress(
this.serviceId4k,
this.externalServiceId4k
);
}
}
if (this.mediaType === MediaType.TV) {
if (
this.externalServiceId !== undefined &&
this.serviceId !== undefined
) {
this.downloadStatus = downloadTracker.getSeriesProgress(
this.serviceId,
this.externalServiceId
);
}
if (
this.externalServiceId4k !== undefined &&
this.serviceId4k !== undefined
) {
this.downloadStatus4k = downloadTracker.getSeriesProgress(
this.serviceId4k,
this.externalServiceId4k
);
}
}
}
}
export default Media;

@ -201,6 +201,18 @@ export class MediaRequest {
}
}
@AfterInsert()
public async autoapprovalNotification(): Promise<void> {
const settings = getSettings().notifications;
if (
settings.autoapprovalEnabled &&
this.status === MediaRequestStatus.APPROVED
) {
this.notifyApprovedOrDeclined();
}
}
@AfterUpdate()
@AfterInsert()
public async updateParentStatus(): Promise<void> {
@ -399,10 +411,17 @@ export class MediaRequest {
tmdbId: movie.id,
year: Number(movie.release_date.slice(0, 4)),
monitored: true,
searchNow: true,
searchNow: !radarrSettings.preventSearch,
})
.then(async (success) => {
if (!success) {
.then(async (radarrMovie) => {
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
radarrMovie.id;
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
radarrMovie.titleSlug;
media[this.is4k ? 'serviceId4k' : 'serviceId'] = radarrSettings?.id;
await mediaRepository.save(media);
})
.catch(async () => {
media.status = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
logger.warn(
@ -423,13 +442,15 @@ export class MediaRequest {
media,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
});
}
});
logger.info('Sent request to Radarr', { label: 'Media Request' });
} catch (e) {
throw new Error(
`[MediaRequest] Request failed to send to radarr: ${e.message}`
);
const errorMessage = `Request failed to send to radarr: ${e.message}`;
logger.error('Request failed to send to Radarr', {
label: 'Media Request',
errorMessage,
});
throw new Error(errorMessage);
}
}
}
@ -501,8 +522,10 @@ export class MediaRequest {
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
});
const series = await tmdb.getTvShow({ tvId: media.tmdbId });
const tvdbId = series.external_ids.tvdb_id ?? media.tvdbId;
if (!series.external_ids.tvdb_id) {
if (!tvdbId) {
this.handleRemoveParentUpdate();
throw new Error('Series was missing tvdb id');
}
@ -539,7 +562,7 @@ export class MediaRequest {
if (this.profileId && this.profileId !== qualityProfile) {
qualityProfile = this.profileId;
logger.info(`Request has an override profile id: ${qualityProfile}`, {
logger.info(`Request has an override profile ID: ${qualityProfile}`, {
label: 'Media Request',
});
}
@ -550,15 +573,32 @@ export class MediaRequest {
profileId: qualityProfile,
rootFolderPath: rootFolder,
title: series.name,
tvdbid: series.external_ids.tvdb_id,
tvdbid: tvdbId,
seasons: this.seasons.map((season) => season.seasonNumber),
seasonFolder: sonarrSettings.enableSeasonFolders,
seriesType,
monitored: true,
searchNow: true,
searchNow: !sonarrSettings.preventSearch,
})
.then(async (success) => {
if (!success) {
.then(async (sonarrSeries) => {
// We grab media again here to make sure we have the latest version of it
const media = await mediaRepository.findOne({
where: { id: this.media.id },
relations: ['requests'],
});
if (!media) {
throw new Error('Media data is missing');
}
media[this.is4k ? 'externalServiceId4k' : 'externalServiceId'] =
sonarrSeries.id;
media[this.is4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
sonarrSeries.titleSlug;
media[this.is4k ? 'serviceId4k' : 'serviceId'] = sonarrSettings?.id;
await mediaRepository.save(media);
})
.catch(async () => {
media.status = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
logger.warn(
@ -586,13 +626,15 @@ export class MediaRequest {
},
],
});
}
});
logger.info('Sent request to Sonarr', { label: 'Media Request' });
} catch (e) {
throw new Error(
`[MediaRequest] Request failed to send to sonarr: ${e.message}`
);
const errorMessage = `Request failed to send to sonarr: ${e.message}`;
logger.error('Request failed to send to Sonarr', {
label: 'Media Request',
errorMessage,
});
throw new Error(errorMessage);
}
}
}

@ -6,6 +6,7 @@ import {
UpdateDateColumn,
OneToMany,
RelationCount,
AfterLoad,
} from 'typeorm';
import { Permission, hasPermission } from '../lib/permissions';
import { MediaRequest } from './MediaRequest';
@ -25,14 +26,19 @@ export class User {
static readonly filteredFields: string[] = ['plexToken', 'password'];
public displayName: string;
@PrimaryGeneratedColumn()
public id: number;
@Column({ unique: true })
public email: string;
@Column()
public username: string;
@Column({ nullable: true })
public plexUsername: string;
@Column({ nullable: true })
public username?: string;
@Column({ nullable: true, select: false })
public password?: string;
@ -125,4 +131,9 @@ export class User {
});
}
}
@AfterLoad()
public setDisplayName(): void {
this.displayName = this.username || this.plexUsername;
}
}

@ -5,6 +5,7 @@ import { createConnection, getRepository } from 'typeorm';
import routes from './routes';
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser';
import csurf from 'csurf';
import session, { Store } from 'express-session';
import { TypeormStore } from 'connect-typeorm/out';
import YAML from 'yamljs';
@ -22,6 +23,7 @@ import { getAppVersion } from './utils/appVersion';
import SlackAgent from './lib/notifications/agents/slack';
import PushoverAgent from './lib/notifications/agents/pushover';
import WebhookAgent from './lib/notifications/agents/webhook';
import { getClientIp } from '@supercharge/request-ip';
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
@ -59,11 +61,47 @@ app
startJobs();
const server = express();
if (settings.main.trustProxy) {
server.enable('trust proxy');
}
server.use(cookieParser());
server.use(bodyParser.json());
server.use(bodyParser.urlencoded({ extended: true }));
server.use((req, res, next) => {
try {
const descriptor = Object.getOwnPropertyDescriptor(req, 'ip');
if (descriptor?.writable === true) {
req.ip = getClientIp(req) ?? '';
}
} catch (e) {
logger.error('Failed to attach the ip to the request', {
label: 'Middleware',
message: e.message,
});
} finally {
next();
}
});
if (settings.main.csrfProtection) {
server.use(
csurf({
cookie: {
httpOnly: true,
sameSite: true,
secure: !dev,
},
})
);
server.use((req, res, next) => {
res.cookie('XSRF-TOKEN', req.csrfToken(), {
sameSite: true,
secure: !dev,
});
next();
});
}
// Setup sessions
// Set up sessions
const sessionRespository = getRepository(Session);
server.use(
'/api',

@ -0,0 +1,45 @@
import { PlexSettings } from '../../lib/settings';
export interface PlexStatus {
settings: PlexSettings;
status: number;
message: string;
}
export interface PlexConnection {
protocol: string;
address: string;
port: number;
uri: string;
local: boolean;
status?: number;
message?: string;
host?: string;
}
export interface PlexDevice {
name: string;
product: string;
productVersion: string;
platform: string;
platformVersion: string;
device: string;
clientIdentifier: string;
createdAt: Date;
lastSeenAt: Date;
provides: string[];
owned: boolean;
accessToken?: string;
publicAddress?: string;
httpsRequired?: boolean;
synced?: boolean;
relay?: boolean;
dnsRebindingProtection?: boolean;
natLoopbackSupported?: boolean;
publicAddressMatches?: boolean;
presence?: boolean;
ownerID?: string;
home?: boolean;
sourceTitle?: string;
connection: PlexConnection[];
}

@ -9,4 +9,5 @@ export interface PublicSettingsResponse {
initialized: boolean;
movie4kEnabled: boolean;
series4kEnabled: boolean;
hideAvailable: boolean;
}

@ -68,6 +68,7 @@ class JobPlexSync {
private async processMovie(plexitem: PlexLibraryItem) {
const mediaRepository = getRepository(Media);
try {
if (plexitem.guid.match(plexRegex)) {
const metadata = await this.plexClient.getMetadata(plexitem.ratingKey);
@ -89,6 +90,15 @@ class JobPlexSync {
newMedia.tmdbId = Number(tmdbMatch);
}
});
if (newMedia.imdbId && !newMedia.tmdbId) {
const tmdbMovie = await this.tmdb.getMovieByImdbId({
imdbId: newMedia.imdbId,
});
newMedia.tmdbId = tmdbMovie.id;
}
if (!newMedia.tmdbId) {
throw new Error('Unable to find TMDb ID');
}
const has4k = metadata.Media.some(
(media) => media.videoResolution === '4k'
@ -129,6 +139,23 @@ class JobPlexSync {
changedExisting = true;
}
if (
(hasOtherResolution || (has4k && !this.enable4kMovie)) &&
existing.ratingKey !== plexitem.ratingKey
) {
existing.ratingKey = plexitem.ratingKey;
changedExisting = true;
}
if (
has4k &&
this.enable4kMovie &&
existing.ratingKey4k !== plexitem.ratingKey
) {
existing.ratingKey4k = plexitem.ratingKey;
changedExisting = true;
}
if (changedExisting) {
await mediaRepository.save(existing);
this.log(
@ -151,6 +178,12 @@ class JobPlexSync {
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE;
newMedia.mediaAddedAt = new Date(plexitem.addedAt * 1000);
newMedia.ratingKey =
hasOtherResolution || (!this.enable4kMovie && has4k)
? plexitem.ratingKey
: undefined;
newMedia.ratingKey4k =
has4k && this.enable4kMovie ? plexitem.ratingKey : undefined;
await mediaRepository.save(newMedia);
this.log(`Saved ${plexitem.title}`);
}
@ -172,14 +205,14 @@ class JobPlexSync {
}
if (!tmdbMovieId) {
throw new Error('Unable to find TMDB ID');
throw new Error('Unable to find TMDb ID');
}
await this.processMovieWithId(plexitem, tmdbMovie, tmdbMovieId);
}
} catch (e) {
this.log(
`Failed to process plex item. ratingKey: ${plexitem.ratingKey}`,
`Failed to process Plex item. ratingKey: ${plexitem.ratingKey}`,
'error',
{
errorMessage: e.message,
@ -233,6 +266,23 @@ class JobPlexSync {
changedExisting = true;
}
if (
(hasOtherResolution || (has4k && !this.enable4kMovie)) &&
existing.ratingKey !== plexitem.ratingKey
) {
existing.ratingKey = plexitem.ratingKey;
changedExisting = true;
}
if (
has4k &&
this.enable4kMovie &&
existing.ratingKey4k !== plexitem.ratingKey
) {
existing.ratingKey4k = plexitem.ratingKey;
changedExisting = true;
}
if (changedExisting) {
await mediaRepository.save(existing);
this.log(
@ -263,6 +313,12 @@ class JobPlexSync {
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE;
newMedia.ratingKey =
hasOtherResolution || (!this.enable4kMovie && has4k)
? plexitem.ratingKey
: undefined;
newMedia.ratingKey4k =
has4k && this.enable4kMovie ? plexitem.ratingKey : undefined;
await mediaRepository.save(newMedia);
this.log(`Saved ${tmdbMovie.title}`);
}
@ -302,12 +358,14 @@ class JobPlexSync {
let tvShow: TmdbTvDetails | null = null;
try {
const metadata = await this.plexClient.getMetadata(
const ratingKey =
plexitem.grandparentRatingKey ??
plexitem.parentRatingKey ??
plexitem.ratingKey,
{ includeChildren: true }
);
plexitem.ratingKey;
const metadata = await this.plexClient.getMetadata(ratingKey, {
includeChildren: true,
});
if (metadata.guid.match(tvdbRegex)) {
const matchedtvdb = metadata.guid.match(tvdbRegex);
@ -333,7 +391,7 @@ class JobPlexSync {
await this.processHamaSpecials(metadata, Number(tvdbId));
} else {
this.log(
`Hama id ${plexitem.guid} detected, but library agent is not set to Hama`,
`Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`,
'warn'
);
}
@ -343,7 +401,7 @@ class JobPlexSync {
if (!animeList.isLoaded()) {
this.log(
`Hama id ${plexitem.guid} detected, but library agent is not set to Hama`,
`Hama ID ${plexitem.guid} detected, but library agent is not set to Hama`,
'warn'
);
} else if (matched?.[1]) {
@ -399,7 +457,7 @@ class JobPlexSync {
return;
}
// Lets get the available seasons from plex
// Lets get the available seasons from Plex
const seasons = tvShow.seasons;
const media = await this.getExisting(tvShow.id, MediaType.TV);
@ -427,13 +485,17 @@ class JobPlexSync {
// Check if we found the matching season and it has all the available episodes
if (matchedPlexSeason) {
// If we have a matched plex season, get its children metadata so we can check details
// If we have a matched Plex season, get its children metadata so we can check details
const episodes = await this.plexClient.getChildrenMetadata(
matchedPlexSeason.ratingKey
);
// Total episodes that are in standard definition (not 4k)
const totalStandard = episodes.filter((episode) =>
episode.Media.some((media) => media.videoResolution !== '4k')
!this.enable4kShow
? true
: episode.Media.some(
(media) => media.videoResolution !== '4k'
)
).length;
// Total episodes that are in 4k
@ -441,6 +503,23 @@ class JobPlexSync {
episode.Media.some((media) => media.videoResolution === '4k')
).length;
if (
media &&
(totalStandard > 0 || (total4k > 0 && !this.enable4kShow)) &&
media.ratingKey !== ratingKey
) {
media.ratingKey = ratingKey;
}
if (
media &&
total4k > 0 &&
this.enable4kShow &&
media.ratingKey4k !== ratingKey
) {
media.ratingKey4k = ratingKey;
}
if (existingSeason) {
// These ternary statements look super confusing, but they are simply
// setting the status to AVAILABLE if all of a type is there, partially if some,
@ -452,9 +531,9 @@ class JobPlexSync {
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status;
existingSeason.status4k =
total4k === season.episode_count
this.enable4kShow && total4k === season.episode_count
? MediaStatus.AVAILABLE
: total4k > 0
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status4k;
} else {
@ -470,9 +549,9 @@ class JobPlexSync {
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k:
total4k === season.episode_count
this.enable4kShow && total4k === season.episode_count
? MediaStatus.AVAILABLE
: total4k > 0
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
})
@ -547,16 +626,32 @@ class JobPlexSync {
media.mediaAddedAt = new Date(plexitem.addedAt * 1000);
}
media.status = isAllStandardSeasons
// If the show is already available, and there are no new seasons, dont adjust
// the status
const shouldStayAvailable =
media.status === MediaStatus.AVAILABLE &&
newSeasons.filter(
(season) => season.status !== MediaStatus.UNKNOWN
).length === 0;
const shouldStayAvailable4k =
media.status4k === MediaStatus.AVAILABLE &&
newSeasons.filter(
(season) => season.status4k !== MediaStatus.UNKNOWN
).length === 0;
media.status =
isAllStandardSeasons || shouldStayAvailable
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) => season.status !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
media.status4k = isAll4kSeasons
media.status4k =
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
? MediaStatus.AVAILABLE
: media.seasons.some(
: this.enable4kShow &&
media.seasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
@ -577,9 +672,11 @@ class JobPlexSync {
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k: isAll4kSeasons
status4k:
isAll4kSeasons && this.enable4kShow
? MediaStatus.AVAILABLE
: newSeasons.some(
: this.enable4kShow &&
newSeasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
@ -594,7 +691,7 @@ class JobPlexSync {
}
} catch (e) {
this.log(
`Failed to process plex item. ratingKey: ${
`Failed to process Plex item. ratingKey: ${
plexitem.grandparentRatingKey ??
plexitem.parentRatingKey ??
plexitem.ratingKey
@ -763,7 +860,8 @@ class JobPlexSync {
this.log(
this.isRecentOnly
? 'Recently Added Scan Complete'
: 'Full Scan Complete'
: 'Full Scan Complete',
'info'
);
} catch (e) {
logger.error('Sync interrupted', {

@ -0,0 +1,248 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import { v4 as uuid } from 'uuid';
import RadarrAPI, { RadarrMovie } from '../../api/radarr';
import { MediaStatus, MediaType } from '../../constants/media';
import Media from '../../entity/Media';
import { getSettings, RadarrSettings } from '../../lib/settings';
import logger from '../../logger';
const BUNDLE_SIZE = 50;
const UPDATE_RATE = 4 * 1000;
interface SyncStatus {
running: boolean;
progress: number;
total: number;
currentServer: RadarrSettings;
servers: RadarrSettings[];
}
class JobRadarrSync {
private running = false;
private progress = 0;
private enable4k = false;
private sessionId: string;
private servers: RadarrSettings[];
private currentServer: RadarrSettings;
private radarrApi: RadarrAPI;
private items: RadarrMovie[] = [];
public async run() {
const settings = getSettings();
const sessionId = uuid();
this.sessionId = sessionId;
this.log('Radarr sync starting', 'info', { sessionId });
try {
this.running = true;
// Remove any duplicate Radarr servers and assign them to the servers field
this.servers = uniqWith(settings.radarr, (radarrA, radarrB) => {
return (
radarrA.hostname === radarrB.hostname &&
radarrA.port === radarrB.port &&
radarrA.baseUrl === radarrB.baseUrl
);
});
this.enable4k = settings.radarr.some((radarr) => radarr.is4k);
if (this.enable4k) {
this.log(
'At least one 4K Radarr server was detected. 4K movie detection is now enabled.',
'info'
);
}
for (const server of this.servers) {
this.currentServer = server;
if (server.syncEnabled) {
this.log(
`Beginning to process Radarr server: ${server.name}`,
'info'
);
this.radarrApi = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
});
this.items = await this.radarrApi.getMovies();
await this.loop({ sessionId });
} else {
this.log(`Sync not enabled. Skipping Radarr server: ${server.name}`);
}
}
this.log('Radarr sync complete', 'info');
} catch (e) {
this.log('Something went wrong.', 'error', { errorMessage: e.message });
} finally {
// If a new scanning session hasnt started, set running back to false
if (this.sessionId === sessionId) {
this.running = false;
}
}
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentServer: this.currentServer,
servers: this.servers,
};
}
public cancel(): void {
this.running = false;
}
private async processRadarrMovie(radarrMovie: RadarrMovie) {
const mediaRepository = getRepository(Media);
const server4k = this.enable4k && this.currentServer.is4k;
const media = await mediaRepository.findOne({
where: { tmdbId: radarrMovie.tmdbId },
});
if (media) {
let isChanged = false;
if (media.status === MediaStatus.AVAILABLE) {
this.log(`Movie already available: ${radarrMovie.title}`);
} else {
media[server4k ? 'status4k' : 'status'] = radarrMovie.downloaded
? MediaStatus.AVAILABLE
: MediaStatus.PROCESSING;
this.log(
`Updated existing ${server4k ? '4K ' : ''}movie ${
radarrMovie.title
} to status ${MediaStatus[media[server4k ? 'status4k' : 'status']]}`
);
isChanged = true;
}
if (
media[server4k ? 'serviceId4k' : 'serviceId'] !== this.currentServer.id
) {
media[server4k ? 'serviceId4k' : 'serviceId'] = this.currentServer.id;
this.log(`Updated service ID for media entity: ${radarrMovie.title}`);
isChanged = true;
}
if (
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] !==
radarrMovie.id
) {
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] =
radarrMovie.id;
this.log(
`Updated external service ID for media entity: ${radarrMovie.title}`
);
isChanged = true;
}
if (
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
radarrMovie.titleSlug
) {
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
radarrMovie.titleSlug;
this.log(
`Updated external service slug for media entity: ${radarrMovie.title}`
);
isChanged = true;
}
if (isChanged) {
await mediaRepository.save(media);
}
} else {
const newMedia = new Media({
tmdbId: radarrMovie.tmdbId,
imdbId: radarrMovie.imdbId,
mediaType: MediaType.MOVIE,
serviceId: !server4k ? this.currentServer.id : undefined,
serviceId4k: server4k ? this.currentServer.id : undefined,
externalServiceId: !server4k ? radarrMovie.id : undefined,
externalServiceId4k: server4k ? radarrMovie.id : undefined,
status:
!server4k && radarrMovie.downloaded
? MediaStatus.AVAILABLE
: !server4k
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
server4k && radarrMovie.downloaded
? MediaStatus.AVAILABLE
: server4k
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
});
this.log(
`Added media for movie ${radarrMovie.title} and set status to ${
MediaStatus[newMedia[server4k ? 'status4k' : 'status']]
}`
);
await mediaRepository.save(newMedia);
}
}
private async processItems(items: RadarrMovie[]) {
await Promise.all(
items.map(async (radarrMovie) => {
await this.processRadarrMovie(radarrMovie);
})
);
}
private async loop({
start = 0,
end = BUNDLE_SIZE,
sessionId,
}: {
start?: number;
end?: number;
sessionId?: string;
} = {}) {
const slicedItems = this.items.slice(start, end);
if (!this.running) {
throw new Error('Sync was aborted.');
}
if (this.sessionId !== sessionId) {
throw new Error('New session was started. Old session aborted.');
}
if (start < this.items.length) {
this.progress = start;
await this.processItems(slicedItems);
await new Promise<void>((resolve, reject) =>
setTimeout(() => {
this.loop({
start: start + BUNDLE_SIZE,
end: end + BUNDLE_SIZE,
sessionId,
})
.then(() => resolve())
.catch((e) => reject(new Error(e.message)));
}, UPDATE_RATE)
);
}
}
private log(
message: string,
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
optional?: Record<string, unknown>
): void {
logger[level](message, { label: 'Radarr Sync', ...optional });
}
}
export const jobRadarrSync = new JobRadarrSync();

@ -1,10 +1,17 @@
import schedule from 'node-schedule';
import { jobPlexFullSync, jobPlexRecentSync } from './plexsync';
import logger from '../logger';
import { jobRadarrSync } from './radarrsync';
import { jobSonarrSync } from './sonarrsync';
import downloadTracker from '../lib/downloadtracker';
interface ScheduledJob {
id: string;
job: schedule.Job;
name: string;
type: 'process' | 'command';
running?: () => boolean;
cancelFn?: () => void;
}
export const scheduledJobs: ScheduledJob[] = [];
@ -12,21 +19,80 @@ export const scheduledJobs: ScheduledJob[] = [];
export const startJobs = (): void => {
// Run recently added plex sync every 5 minutes
scheduledJobs.push({
id: 'plex-recently-added-sync',
name: 'Plex Recently Added Sync',
type: 'process',
job: schedule.scheduleJob('0 */5 * * * *', () => {
logger.info('Starting scheduled job: Plex Recently Added Sync', {
label: 'Jobs',
});
jobPlexRecentSync.run();
}),
running: () => jobPlexRecentSync.status().running,
cancelFn: () => jobPlexRecentSync.cancel(),
});
// Run full plex sync every 24 hours
scheduledJobs.push({
id: 'plex-full-sync',
name: 'Plex Full Library Sync',
type: 'process',
job: schedule.scheduleJob('0 0 3 * * *', () => {
logger.info('Starting scheduled job: Plex Full Sync', { label: 'Jobs' });
jobPlexFullSync.run();
}),
running: () => jobPlexFullSync.status().running,
cancelFn: () => jobPlexFullSync.cancel(),
});
// Run full radarr sync every 24 hours
scheduledJobs.push({
id: 'radarr-sync',
name: 'Radarr Sync',
type: 'process',
job: schedule.scheduleJob('0 0 4 * * *', () => {
logger.info('Starting scheduled job: Radarr Sync', { label: 'Jobs' });
jobRadarrSync.run();
}),
running: () => jobRadarrSync.status().running,
cancelFn: () => jobRadarrSync.cancel(),
});
// Run full sonarr sync every 24 hours
scheduledJobs.push({
id: 'sonarr-sync',
name: 'Sonarr Sync',
type: 'process',
job: schedule.scheduleJob('0 30 4 * * *', () => {
logger.info('Starting scheduled job: Sonarr Sync', { label: 'Jobs' });
jobSonarrSync.run();
}),
running: () => jobSonarrSync.status().running,
cancelFn: () => jobSonarrSync.cancel(),
});
// Run download sync
scheduledJobs.push({
id: 'download-sync',
name: 'Download Sync',
type: 'command',
job: schedule.scheduleJob('0 * * * * *', () => {
logger.debug('Starting scheduled job: Download Sync', { label: 'Jobs' });
downloadTracker.updateDownloads();
}),
});
// Reset download sync
scheduledJobs.push({
id: 'download-sync-reset',
name: 'Download Sync Reset',
type: 'command',
job: schedule.scheduleJob('0 0 1 * * *', () => {
logger.info('Starting scheduled job: Download Sync Reset', {
label: 'Jobs',
});
downloadTracker.resetDownloadTracker();
}),
});
logger.info('Scheduled jobs loaded', { label: 'Jobs' });

@ -0,0 +1,370 @@
import { uniqWith } from 'lodash';
import { getRepository } from 'typeorm';
import { v4 as uuid } from 'uuid';
import SonarrAPI, { SonarrSeries } from '../../api/sonarr';
import TheMovieDb, { TmdbTvDetails } from '../../api/themoviedb';
import { MediaStatus, MediaType } from '../../constants/media';
import Media from '../../entity/Media';
import Season from '../../entity/Season';
import { getSettings, SonarrSettings } from '../../lib/settings';
import logger from '../../logger';
const BUNDLE_SIZE = 50;
const UPDATE_RATE = 4 * 1000;
interface SyncStatus {
running: boolean;
progress: number;
total: number;
currentServer: SonarrSettings;
servers: SonarrSettings[];
}
class JobSonarrSync {
private running = false;
private progress = 0;
private enable4k = false;
private sessionId: string;
private servers: SonarrSettings[];
private currentServer: SonarrSettings;
private sonarrApi: SonarrAPI;
private items: SonarrSeries[] = [];
public async run() {
const settings = getSettings();
const sessionId = uuid();
this.sessionId = sessionId;
this.log('Sonarr sync starting', 'info', { sessionId });
try {
this.running = true;
// Remove any duplicate Sonarr servers and assign them to the servers field
this.servers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => {
return (
sonarrA.hostname === sonarrB.hostname &&
sonarrA.port === sonarrB.port &&
sonarrA.baseUrl === sonarrB.baseUrl
);
});
this.enable4k = settings.sonarr.some((sonarr) => sonarr.is4k);
if (this.enable4k) {
this.log(
'At least one 4K Sonarr server was detected. 4K movie detection is now enabled.',
'info'
);
}
for (const server of this.servers) {
this.currentServer = server;
if (server.syncEnabled) {
this.log(
`Beginning to process Sonarr server: ${server.name}`,
'info'
);
this.sonarrApi = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
});
this.items = await this.sonarrApi.getSeries();
await this.loop({ sessionId });
} else {
this.log(`Sync not enabled. Skipping Sonarr server: ${server.name}`);
}
}
this.log('Sonarr sync complete', 'info');
} catch (e) {
this.log('Something went wrong.', 'error', { errorMessage: e.message });
} finally {
// If a new scanning session hasnt started, set running back to false
if (this.sessionId === sessionId) {
this.running = false;
}
}
}
public status(): SyncStatus {
return {
running: this.running,
progress: this.progress,
total: this.items.length,
currentServer: this.currentServer,
servers: this.servers,
};
}
public cancel(): void {
this.running = false;
}
private async processSonarrSeries(sonarrSeries: SonarrSeries) {
const mediaRepository = getRepository(Media);
const server4k = this.enable4k && this.currentServer.is4k;
const media = await mediaRepository.findOne({
where: { tvdbId: sonarrSeries.tvdbId },
});
const currentSeasonsAvailable = (media?.seasons ?? []).filter(
(season) =>
season[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
).length;
const newSeasons: Season[] = [];
for (const season of sonarrSeries.seasons) {
const existingSeason = media?.seasons.find(
(es) => es.seasonNumber === season.seasonNumber
);
// We are already tracking this season so we can work on it directly
if (existingSeason) {
if (
existingSeason[server4k ? 'status4k' : 'status'] !==
MediaStatus.AVAILABLE &&
season.statistics
) {
existingSeason[server4k ? 'status4k' : 'status'] =
season.statistics.episodeFileCount ===
season.statistics.totalEpisodeCount
? MediaStatus.AVAILABLE
: season.statistics.episodeFileCount > 0
? MediaStatus.PARTIALLY_AVAILABLE
: season.monitored
? MediaStatus.PROCESSING
: existingSeason[server4k ? 'status4k' : 'status'];
}
} else {
if (season.statistics && season.seasonNumber !== 0) {
const allEpisodes =
season.statistics.episodeFileCount ===
season.statistics.totalEpisodeCount;
newSeasons.push(
new Season({
seasonNumber: season.seasonNumber,
status:
!server4k && allEpisodes
? MediaStatus.AVAILABLE
: !server4k && season.statistics.episodeFileCount > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !server4k && season.monitored
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
server4k && allEpisodes
? MediaStatus.AVAILABLE
: server4k && season.statistics.episodeFileCount > 0
? MediaStatus.PARTIALLY_AVAILABLE
: !server4k && season.monitored
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
})
);
}
}
}
const filteredSeasons = sonarrSeries.seasons.filter(
(s) => s.seasonNumber !== 0
);
const isAllSeasons =
(media?.seasons ?? []).filter(
(s) => s[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
).length +
newSeasons.filter(
(s) => s[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
).length >=
filteredSeasons.length && filteredSeasons.length > 0;
if (media) {
media.seasons = [...media.seasons, ...newSeasons];
const newSeasonsAvailable = (media?.seasons ?? []).filter(
(season) =>
season[server4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE
).length;
if (newSeasonsAvailable > currentSeasonsAvailable) {
this.log(
`Detected ${newSeasonsAvailable - currentSeasonsAvailable} new ${
server4k ? '4K ' : ''
}season(s) for ${sonarrSeries.title}`,
'debug'
);
media.lastSeasonChange = new Date();
}
if (
media[server4k ? 'serviceId4k' : 'serviceId'] !== this.currentServer.id
) {
media[server4k ? 'serviceId4k' : 'serviceId'] = this.currentServer.id;
this.log(`Updated service ID for media entity: ${sonarrSeries.title}`);
}
if (
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] !==
sonarrSeries.id
) {
media[server4k ? 'externalServiceId4k' : 'externalServiceId'] =
sonarrSeries.id;
this.log(
`Updated external service ID for media entity: ${sonarrSeries.title}`
);
}
if (
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] !==
sonarrSeries.titleSlug
) {
media[server4k ? 'externalServiceSlug4k' : 'externalServiceSlug'] =
sonarrSeries.titleSlug;
this.log(
`Updated external service slug for media entity: ${sonarrSeries.title}`
);
}
// If the show is already available, and there are no new seasons, dont adjust
// the status
const shouldStayAvailable =
media.status === MediaStatus.AVAILABLE &&
newSeasons.filter(
(season) =>
season[server4k ? 'status4k' : 'status'] !== MediaStatus.UNKNOWN
).length === 0;
media[server4k ? 'status4k' : 'status'] =
isAllSeasons || shouldStayAvailable
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) => season.status !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
await mediaRepository.save(media);
} else {
const tmdb = new TheMovieDb();
let tvShow: TmdbTvDetails;
try {
tvShow = await tmdb.getShowByTvdbId({
tvdbId: sonarrSeries.tvdbId,
});
} catch (e) {
this.log(
'Failed to create new media item during sync. TVDB ID is missing from TMDB?',
'warn',
{ sonarrSeries, errorMessage: e.message }
);
return;
}
const newMedia = new Media({
tmdbId: tvShow.id,
tvdbId: sonarrSeries.tvdbId,
mediaType: MediaType.TV,
serviceId: !server4k ? this.currentServer.id : undefined,
serviceId4k: server4k ? this.currentServer.id : undefined,
externalServiceId: !server4k ? sonarrSeries.id : undefined,
externalServiceId4k: server4k ? sonarrSeries.id : undefined,
externalServiceSlug: !server4k ? sonarrSeries.titleSlug : undefined,
externalServiceSlug4k: server4k ? sonarrSeries.titleSlug : undefined,
seasons: newSeasons,
status:
!server4k && isAllSeasons
? MediaStatus.AVAILABLE
: !server4k &&
newSeasons.some(
(s) =>
s.status === MediaStatus.PARTIALLY_AVAILABLE ||
s.status === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: !server4k
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
status4k:
server4k && isAllSeasons
? MediaStatus.AVAILABLE
: server4k &&
newSeasons.some(
(s) =>
s.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
s.status4k === MediaStatus.AVAILABLE
)
? MediaStatus.PARTIALLY_AVAILABLE
: server4k
? MediaStatus.PROCESSING
: MediaStatus.UNKNOWN,
});
this.log(
`Added media for series ${sonarrSeries.title} and set status to ${
MediaStatus[newMedia[server4k ? 'status4k' : 'status']]
}`
);
await mediaRepository.save(newMedia);
}
}
private async processItems(items: SonarrSeries[]) {
await Promise.all(
items.map(async (sonarrSeries) => {
await this.processSonarrSeries(sonarrSeries);
})
);
}
private async loop({
start = 0,
end = BUNDLE_SIZE,
sessionId,
}: {
start?: number;
end?: number;
sessionId?: string;
} = {}) {
const slicedItems = this.items.slice(start, end);
if (!this.running) {
throw new Error('Sync was aborted.');
}
if (this.sessionId !== sessionId) {
throw new Error('New session was started. Old session aborted.');
}
if (start < this.items.length) {
this.progress = start;
await this.processItems(slicedItems);
await new Promise<void>((resolve, reject) =>
setTimeout(() => {
this.loop({
start: start + BUNDLE_SIZE,
end: end + BUNDLE_SIZE,
sessionId,
})
.then(() => resolve())
.catch((e) => reject(new Error(e.message)));
}, UPDATE_RATE)
);
}
}
private log(
message: string,
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
optional?: Record<string, unknown>
): void {
logger[level](message, { label: 'Sonarr Sync', ...optional });
}
}
export const jobSonarrSync = new JobSonarrSync();

@ -0,0 +1,195 @@
import { uniqWith } from 'lodash';
import RadarrAPI from '../api/radarr';
import SonarrAPI from '../api/sonarr';
import { MediaType } from '../constants/media';
import logger from '../logger';
import { getSettings } from './settings';
export interface DownloadingItem {
mediaType: MediaType;
externalId: number;
size: number;
sizeLeft: number;
status: string;
timeLeft: string;
estimatedCompletionTime: Date;
title: string;
}
class DownloadTracker {
private radarrServers: Record<number, DownloadingItem[]> = {};
private sonarrServers: Record<number, DownloadingItem[]> = {};
public getMovieProgress(
serverId: number,
externalServiceId: number
): DownloadingItem[] {
if (!this.radarrServers[serverId]) {
return [];
}
return this.radarrServers[serverId].filter(
(item) => item.externalId === externalServiceId
);
}
public getSeriesProgress(
serverId: number,
externalServiceId: number
): DownloadingItem[] {
if (!this.sonarrServers[serverId]) {
return [];
}
return this.sonarrServers[serverId].filter(
(item) => item.externalId === externalServiceId
);
}
public async resetDownloadTracker() {
this.radarrServers = {};
}
public updateDownloads() {
this.updateRadarrDownloads();
this.updateSonarrDownloads();
}
private async updateRadarrDownloads() {
const settings = getSettings();
// Remove duplicate servers
const filteredServers = uniqWith(settings.radarr, (radarrA, radarrB) => {
return (
radarrA.hostname === radarrB.hostname &&
radarrA.port === radarrB.port &&
radarrA.baseUrl === radarrB.baseUrl
);
});
// Load downloads from Radarr servers
Promise.all(
filteredServers.map(async (server) => {
if (server.syncEnabled) {
const radarr = new RadarrAPI({
apiKey: server.apiKey,
url: RadarrAPI.buildRadarrUrl(server, '/api/v3'),
});
const queueItems = await radarr.getQueue();
this.radarrServers[server.id] = queueItems.map((item) => ({
externalId: item.movieId,
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
mediaType: MediaType.MOVIE,
size: item.size,
sizeLeft: item.sizeleft,
status: item.status,
timeLeft: item.timeleft,
title: item.title,
}));
if (queueItems.length > 0) {
logger.debug(
`Found ${queueItems.length} item(s) in progress on Radarr server: ${server.name}`,
{ label: 'Download Tracker' }
);
}
// Duplicate this data to matching servers
const matchingServers = settings.radarr.filter(
(rs) =>
rs.hostname === server.hostname &&
rs.port === server.port &&
rs.baseUrl === server.baseUrl &&
rs.id !== server.id
);
if (matchingServers.length > 0) {
logger.debug(
`Matching download data to ${matchingServers.length} other Radarr server(s)`,
{ label: 'Download Tracker' }
);
}
matchingServers.forEach((ms) => {
if (ms.syncEnabled) {
this.radarrServers[ms.id] = this.radarrServers[server.id];
}
});
}
})
);
}
private async updateSonarrDownloads() {
const settings = getSettings();
// Remove duplicate servers
const filteredServers = uniqWith(settings.sonarr, (sonarrA, sonarrB) => {
return (
sonarrA.hostname === sonarrB.hostname &&
sonarrA.port === sonarrB.port &&
sonarrA.baseUrl === sonarrB.baseUrl
);
});
// Load downloads from Radarr servers
Promise.all(
filteredServers.map(async (server) => {
if (server.syncEnabled) {
const radarr = new SonarrAPI({
apiKey: server.apiKey,
url: SonarrAPI.buildSonarrUrl(server, '/api/v3'),
});
const queueItems = await radarr.getQueue();
this.sonarrServers[server.id] = queueItems.map((item) => ({
externalId: item.seriesId,
estimatedCompletionTime: new Date(item.estimatedCompletionTime),
mediaType: MediaType.TV,
size: item.size,
sizeLeft: item.sizeleft,
status: item.status,
timeLeft: item.timeleft,
title: item.title,
}));
if (queueItems.length > 0) {
logger.debug(
`Found ${queueItems.length} item(s) in progress on Sonarr server: ${server.name}`,
{ label: 'Download Tracker' }
);
}
// Duplicate this data to matching servers
const matchingServers = settings.sonarr.filter(
(rs) =>
rs.hostname === server.hostname &&
rs.port === server.port &&
rs.baseUrl === server.baseUrl &&
rs.id !== server.id
);
if (matchingServers.length > 0) {
logger.debug(
`Matching download data to ${matchingServers.length} other Sonarr server(s)`,
{ label: 'Download Tracker' }
);
}
matchingServers.forEach((ms) => {
if (ms.syncEnabled) {
this.sonarrServers[ms.id] = this.sonarrServers[server.id];
}
});
}
})
);
}
}
const downloadTracker = new DownloadTracker();
export default downloadTracker;

@ -104,7 +104,7 @@ class DiscordAgent
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.username ?? '',
value: payload.notifyUser.displayName ?? '',
inline: true,
},
{
@ -126,7 +126,7 @@ class DiscordAgent
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.username ?? '',
value: payload.notifyUser.displayName ?? '',
inline: true,
},
{
@ -148,7 +148,7 @@ class DiscordAgent
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.username ?? '',
value: payload.notifyUser.displayName ?? '',
inline: true,
},
{
@ -170,7 +170,7 @@ class DiscordAgent
fields.push(
{
name: 'Requested By',
value: payload.notifyUser.username ?? '',
value: payload.notifyUser.displayName ?? '',
inline: true,
},
{

@ -60,7 +60,7 @@ class EmailAgent
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.username,
requestedBy: payload.notifyUser.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
@ -106,7 +106,7 @@ class EmailAgent
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.username,
requestedBy: payload.notifyUser.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
@ -144,7 +144,7 @@ class EmailAgent
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.username,
requestedBy: payload.notifyUser.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
@ -181,7 +181,7 @@ class EmailAgent
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.username,
requestedBy: payload.notifyUser.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
@ -218,7 +218,7 @@ class EmailAgent
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.username,
requestedBy: payload.notifyUser.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,

@ -48,42 +48,42 @@ class PushoverAgent
const title = payload.subject;
const plot = payload.message;
const user = payload.notifyUser.username;
const username = payload.notifyUser.displayName;
switch (type) {
case Notification.MEDIA_PENDING:
messageTitle = 'New Request';
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `<b>Requested By</b>\n${user}\n\n`;
message += `<b>Requested By</b>\n${username}\n\n`;
message += `<b>Status</b>\nPending Approval\n`;
break;
case Notification.MEDIA_APPROVED:
messageTitle = 'Request Approved';
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `<b>Requested By</b>\n${user}\n\n`;
message += `<b>Requested By</b>\n${username}\n\n`;
message += `<b>Status</b>\nProcessing Request\n`;
break;
case Notification.MEDIA_AVAILABLE:
messageTitle = 'Now available!';
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `<b>Requested By</b>\n${user}\n\n`;
message += `<b>Requested By</b>\n${username}\n\n`;
message += `<b>Status</b>\nAvailable\n`;
break;
case Notification.MEDIA_DECLINED:
messageTitle = 'Request Declined';
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `<b>Requested By</b>\n${user}\n\n`;
message += `<b>Requested By</b>\n${username}\n\n`;
message += `<b>Status</b>\nDeclined\n`;
break;
case Notification.TEST_NOTIFICATION:
messageTitle = 'Test Notification';
message += `${title}\n\n`;
message += `${plot}\n\n`;
message += `<b>Requested By</b>\n${user}\n`;
message += `<b>Requested By</b>\n${username}\n`;
break;
}

@ -69,7 +69,7 @@ class SlackAgent
fields.push(
{
type: 'mrkdwn',
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`,
},
{
type: 'mrkdwn',
@ -85,7 +85,7 @@ class SlackAgent
fields.push(
{
type: 'mrkdwn',
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`,
},
{
type: 'mrkdwn',
@ -101,7 +101,7 @@ class SlackAgent
fields.push(
{
type: 'mrkdwn',
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`,
},
{
type: 'mrkdwn',
@ -117,7 +117,7 @@ class SlackAgent
fields.push(
{
type: 'mrkdwn',
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
text: `*Requested By*\n${payload.notifyUser.displayName ?? ''}`,
},
{
type: 'mrkdwn',

@ -51,7 +51,7 @@ class TelegramAgent
const title = this.escapeText(payload.subject);
const plot = this.escapeText(payload.message);
const user = this.escapeText(payload.notifyUser.username);
const user = this.escapeText(payload.notifyUser.displayName);
/* eslint-disable no-useless-escape */
switch (type) {

@ -16,7 +16,7 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
subject: 'subject',
message: 'message',
image: 'image',
notifyuser_username: 'notifyUser.username',
notifyuser_username: 'notifyUser.displayName',
notifyuser_email: 'notifyUser.email',
notifyuser_avatar: 'notifyUser.avatar',
media_tmdbid: 'media.tmdbId',

@ -1,4 +1,5 @@
import logger from '../../logger';
import { getSettings } from '../settings';
import type { NotificationAgent, NotificationPayload } from './agents/agent';
export enum Notification {
@ -43,11 +44,12 @@ class NotificationManager {
type: Notification,
payload: NotificationPayload
): void {
const settings = getSettings().notifications;
logger.info(`Sending notification for ${Notification[type]}`, {
label: 'Notifications',
});
this.activeAgents.forEach((agent) => {
if (agent.shouldSend(type)) {
if (settings.enabled && agent.shouldSend(type)) {
agent.send(type, payload);
}
});

@ -32,6 +32,9 @@ interface DVRSettings {
activeDirectory: string;
is4k: boolean;
isDefault: boolean;
externalUrl?: string;
syncEnabled: boolean;
preventSearch: boolean;
}
export interface RadarrSettings extends DVRSettings {
@ -48,7 +51,10 @@ export interface SonarrSettings extends DVRSettings {
export interface MainSettings {
apiKey: string;
applicationUrl: string;
csrfProtection: boolean;
defaultPermissions: number;
hideAvailable: boolean;
trustProxy: boolean;
}
interface PublicSettings {
@ -58,6 +64,7 @@ interface PublicSettings {
interface FullPublicSettings extends PublicSettings {
movie4kEnabled: boolean;
series4kEnabled: boolean;
hideAvailable: boolean;
}
export interface NotificationAgentConfig {
@ -124,6 +131,8 @@ interface NotificationAgents {
}
interface NotificationSettings {
enabled: boolean;
autoapprovalEnabled: boolean;
agents: NotificationAgents;
}
@ -150,7 +159,10 @@ class Settings {
main: {
apiKey: '',
applicationUrl: '',
csrfProtection: false,
defaultPermissions: Permission.REQUEST,
hideAvailable: false,
trustProxy: false,
},
plex: {
name: '',
@ -165,6 +177,8 @@ class Settings {
initialized: false,
},
notifications: {
enabled: true,
autoapprovalEnabled: false,
agents: {
email: {
enabled: false,
@ -281,6 +295,7 @@ class Settings {
series4kEnabled: this.data.sonarr.some(
(sonarr) => sonarr.is4k && sonarr.isDefault
),
hideAvailable: this.data.main.hideAvailable,
};
}

@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDisplayNameToUser1611508672722 implements MigrationInterface {
name = 'AddDisplayNameToUser1611508672722';
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 NOT NULL, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
);
await queryRunner.query(
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "username" FROM "user"`
);
await queryRunner.query(`DROP TABLE "user"`);
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
await queryRunner.query(
`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
);
await queryRunner.query(
`INSERT INTO "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "user"`
);
await queryRunner.query(`DROP TABLE "user"`);
await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`);
}
public async down(queryRunner: QueryRunner): Promise<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 NOT NULL, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), "plexUsername" varchar, CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
);
await queryRunner.query(
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType", "plexUsername" FROM "temporary_user"`
);
await queryRunner.query(`DROP TABLE "temporary_user"`);
await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`);
await queryRunner.query(
`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "username" varchar NOT NULL, "plexId" integer, "plexToken" varchar, "permissions" integer NOT NULL DEFAULT (0), "avatar" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "password" varchar, "userType" integer NOT NULL DEFAULT (1), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
);
await queryRunner.query(
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt", "password", "userType" FROM "temporary_user"`
);
await queryRunner.query(`DROP TABLE "temporary_user"`);
}
}

@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class SonarrRadarrSyncServiceFields1611757511674
implements MigrationInterface {
name = 'SonarrRadarrSyncServiceFields1611757511674';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
await queryRunner.query(
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
);
await queryRunner.query(
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt" FROM "media"`
);
await queryRunner.query(`DROP TABLE "media"`);
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
await queryRunner.query(
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
await queryRunner.query(
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
);
await queryRunner.query(
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt" FROM "temporary_media"`
);
await queryRunner.query(`DROP TABLE "temporary_media"`);
await queryRunner.query(
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
);
}
}

@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRatingKeysToMedia1611801511397 implements MigrationInterface {
name = 'AddRatingKeysToMedia1611801511397';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
await queryRunner.query(
`CREATE TABLE "temporary_media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, "ratingKey" varchar, "ratingKey4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
);
await queryRunner.query(
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k" FROM "media"`
);
await queryRunner.query(`DROP TABLE "media"`);
await queryRunner.query(`ALTER TABLE "temporary_media" RENAME TO "media"`);
await queryRunner.query(
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5"`);
await queryRunner.query(`DROP INDEX "IDX_41a289eb1fa489c1bc6f38d9c3"`);
await queryRunner.query(`DROP INDEX "IDX_7ff2d11f6a83cb52386eaebe74"`);
await queryRunner.query(`ALTER TABLE "media" RENAME TO "temporary_media"`);
await queryRunner.query(
`CREATE TABLE "media" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "mediaType" varchar NOT NULL, "tmdbId" integer NOT NULL, "tvdbId" integer, "imdbId" varchar, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "lastSeasonChange" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "status4k" integer NOT NULL DEFAULT (1), "mediaAddedAt" datetime, "serviceId" integer, "serviceId4k" integer, "externalServiceId" integer, "externalServiceId4k" integer, "externalServiceSlug" varchar, "externalServiceSlug4k" varchar, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
);
await queryRunner.query(
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k", "mediaAddedAt", "serviceId", "serviceId4k", "externalServiceId", "externalServiceId4k", "externalServiceSlug", "externalServiceSlug4k" FROM "temporary_media"`
);
await queryRunner.query(`DROP TABLE "temporary_media"`);
await queryRunner.query(
`CREATE INDEX "IDX_7157aad07c73f6a6ae3bbd5ef5" ON "media" ("tmdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_41a289eb1fa489c1bc6f38d9c3" ON "media" ("tvdbId") `
);
await queryRunner.query(
`CREATE INDEX "IDX_7ff2d11f6a83cb52386eaebe74" ON "media" ("imdbId") `
);
}
}

@ -72,6 +72,7 @@ export interface MovieDetails {
};
mediaInfo?: Media;
externalIds: ExternalIds;
plexUrl?: string;
}
export const mapMovieDetails = (

@ -48,13 +48,17 @@ authRoutes.post('/login', async (req, res, next) => {
// Let's check if their plex token is up to date
if (user.plexToken !== body.authToken) {
user.plexToken = body.authToken;
await userRepository.save(user);
}
// Update the users avatar with their plex thumbnail (incase it changed)
user.avatar = account.thumb;
user.email = account.email;
user.username = account.username;
user.plexUsername = account.username;
if (user.username === account.username) {
user.username = '';
}
await userRepository.save(user);
} else {
// Here we check if it's the first user. If it is, we create the user with no check
// and give them admin permissions
@ -63,7 +67,7 @@ authRoutes.post('/login', async (req, res, next) => {
if (totalUsers === 0) {
user = new User({
email: account.email,
username: account.username,
plexUsername: account.username,
plexId: account.id,
plexToken: account.authToken,
permissions: Permission.ADMIN,
@ -86,7 +90,7 @@ authRoutes.post('/login', async (req, res, next) => {
if (await mainPlexTv.checkUserAccess(account)) {
user = new User({
email: account.email,
username: account.username,
plexUsername: account.username,
plexId: account.id,
plexToken: account.authToken,
permissions: settings.main.defaultPermissions,
@ -141,7 +145,7 @@ authRoutes.post('/local', async (req, res, next) => {
try {
const user = await userRepository.findOne({
select: ['id', 'password'],
where: { email: body.email, userType: UserType.LOCAL },
where: { email: body.email },
});
const isCorrectCredentials = await user?.passwordMatch(body.password);
@ -151,6 +155,7 @@ authRoutes.post('/local', async (req, res, next) => {
logger.info('Failed login attempt from user with incorrect credentials', {
label: 'Auth',
account: {
ip: req.ip,
email: body.email,
password: '__REDACTED__',
},
@ -181,7 +186,7 @@ authRoutes.get('/logout', (req, res, next) => {
if (err) {
return next({
status: 500,
message: 'Something went wrong while attempting to logout',
message: 'Something went wrong while attempting to sign out.',
});
}

@ -1,7 +1,7 @@
import { Router } from 'express';
import { getRepository, FindOperator, FindOneOptions } from 'typeorm';
import { getRepository, FindOperator, FindOneOptions, In } from 'typeorm';
import Media from '../entity/Media';
import { MediaStatus } from '../constants/media';
import { MediaStatus, MediaType } from '../constants/media';
import logger from '../logger';
import { isAuthenticated } from '../middleware/auth';
import { Permission } from '../lib/permissions';
@ -27,6 +27,12 @@ mediaRoutes.get('/', async (req, res, next) => {
case 'partial':
statusFilter = MediaStatus.PARTIALLY_AVAILABLE;
break;
case 'allavailable':
statusFilter = In([
MediaStatus.AVAILABLE,
MediaStatus.PARTIALLY_AVAILABLE,
]);
break;
case 'processing':
statusFilter = MediaStatus.PROCESSING;
break;
@ -76,6 +82,63 @@ mediaRoutes.get('/', async (req, res, next) => {
}
});
mediaRoutes.get<
{
id: string;
status: 'available' | 'partial' | 'processing' | 'pending' | 'unknown';
},
Media
>(
'/:id/:status',
isAuthenticated(Permission.MANAGE_REQUESTS),
async (req, res, next) => {
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({
where: { id: Number(req.params.id) },
});
if (!media) {
return next({ status: 404, message: 'Media does not exist.' });
}
const is4k = Boolean(req.query.is4k);
switch (req.params.status) {
case 'available':
media[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE;
if (media.mediaType === MediaType.TV) {
// Mark all seasons available
media.seasons.forEach((season) => {
season[is4k ? 'status4k' : 'status'] = MediaStatus.AVAILABLE;
});
}
break;
case 'partial':
if (media.mediaType === MediaType.MOVIE) {
return next({
status: 400,
message: 'Only series can be set to be partially available',
});
}
media.status = MediaStatus.PARTIALLY_AVAILABLE;
break;
case 'processing':
media.status = MediaStatus.PROCESSING;
break;
case 'pending':
media.status = MediaStatus.PENDING;
break;
case 'unknown':
media.status = MediaStatus.UNKNOWN;
}
await mediaRepository.save(media);
return res.status(200).json(media);
}
);
mediaRoutes.delete(
'/:id',
isAuthenticated(Permission.MANAGE_REQUESTS),

@ -109,25 +109,45 @@ requestRoutes.post(
if (!media) {
media = new Media({
tmdbId: tmdbMedia.id,
tvdbId: tmdbMedia.external_ids.tvdb_id,
tvdbId: req.body.tvdbId ?? tmdbMedia.external_ids.tvdb_id,
status: !req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
status4k: req.body.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN,
mediaType: req.body.mediaType,
});
await mediaRepository.save(media);
} else {
if (media.status === MediaStatus.UNKNOWN && !req.body.is4k) {
media.status = MediaStatus.PENDING;
await mediaRepository.save(media);
}
if (media.status4k === MediaStatus.UNKNOWN && req.body.is4k) {
media.status4k = MediaStatus.PENDING;
await mediaRepository.save(media);
}
}
if (req.body.mediaType === 'movie') {
const existing = await requestRepository.findOne({
where: {
media: {
tmdbId: tmdbMedia.id,
},
requestedBy: req.user,
is4k: req.body.is4k,
},
});
if (existing) {
logger.warn('Duplicate request for media blocked', {
tmdbId: tmdbMedia.id,
mediaType: req.body.mediaType,
});
return next({
status: 409,
message: 'Request for this media already exists.',
});
}
await mediaRepository.save(media);
const request = new MediaRequest({
type: MediaType.MOVIE,
media,
@ -185,6 +205,8 @@ requestRoutes.post(
});
}
await mediaRepository.save(media);
const request = new MediaRequest({
type: MediaType.TV,
media: {

@ -6,6 +6,8 @@ import {
ServiceCommonServerWithDetails,
} from '../interfaces/api/serviceInterfaces';
import { getSettings } from '../lib/settings';
import TheMovieDb from '../api/themoviedb';
import logger from '../logger';
const serviceRoutes = Router();
@ -100,13 +102,13 @@ serviceRoutes.get<{ sonarrId: string }>(
const settings = getSettings();
const sonarrSettings = settings.sonarr.find(
(radarr) => radarr.id === Number(req.params.sonarrId)
(sonarr) => sonarr.id === Number(req.params.sonarrId)
);
if (!sonarrSettings) {
return next({
status: 404,
message: 'Radarr server with provided ID does not exist.',
message: 'Sonarr server with provided ID does not exist.',
});
}
@ -145,4 +147,52 @@ serviceRoutes.get<{ sonarrId: string }>(
}
);
serviceRoutes.get<{ tmdbId: string }>(
'/sonarr/lookup/:tmdbId',
async (req, res, next) => {
const settings = getSettings();
const tmdb = new TheMovieDb();
const sonarrSettings = settings.sonarr[0];
if (!sonarrSettings) {
logger.error('No sonarr server has been setup', {
label: 'Media Request',
});
return next({
status: 404,
message: 'No sonarr server has been setup',
});
}
const sonarr = new SonarrAPI({
apiKey: sonarrSettings.apiKey,
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
sonarrSettings.hostname
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
});
try {
const tv = await tmdb.getTvShow({
tvId: Number(req.params.tmdbId),
language: req.query.language as string,
});
const response = await sonarr.getSeriesByTitle(tv.name);
return res.status(200).json(response);
} catch (e) {
logger.error('Failed to fetch tvdb search results', {
label: 'Media Request',
message: e.message,
});
return next({
status: 500,
message: 'Something went wrong trying to fetch series information',
});
}
}
);
export default serviceRoutes;

@ -1,18 +1,10 @@
import { Router } from 'express';
import {
getSettings,
RadarrSettings,
SonarrSettings,
Library,
MainSettings,
} from '../../lib/settings';
import { getSettings, Library, MainSettings } from '../../lib/settings';
import { getRepository } from 'typeorm';
import { User } from '../../entity/User';
import PlexAPI from '../../api/plexapi';
import PlexTvAPI from '../../api/plextv';
import { jobPlexFullSync } from '../../job/plexsync';
import SonarrAPI from '../../api/sonarr';
import RadarrAPI from '../../api/radarr';
import logger from '../../logger';
import { scheduledJobs } from '../../job/schedule';
import { Permission } from '../../lib/permissions';
import { isAuthenticated } from '../../middleware/auth';
@ -22,10 +14,14 @@ import { MediaRequest } from '../../entity/MediaRequest';
import { getAppVersion } from '../../utils/appVersion';
import { SettingsAboutResponse } from '../../interfaces/api/settingsInterfaces';
import notificationRoutes from './notifications';
import sonarrRoutes from './sonarr';
import radarrRoutes from './radarr';
const settingsRoutes = Router();
settingsRoutes.use('/notifications', notificationRoutes);
settingsRoutes.use('/radarr', radarrRoutes);
settingsRoutes.use('/sonarr', sonarrRoutes);
const filteredMainSettings = (
user: User,
@ -106,6 +102,69 @@ settingsRoutes.post('/plex', async (req, res, next) => {
return res.status(200).json(settings.plex);
});
settingsRoutes.get('/plex/devices/servers', async (req, res, next) => {
const userRepository = getRepository(User);
const regexp = /(http(s?):\/\/)(.*)(:[0-9]*)/;
try {
const admin = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
const plexTvClient = admin.plexToken
? new PlexTvAPI(admin.plexToken)
: null;
const devices = (await plexTvClient?.getDevices())?.filter((device) => {
return device.provides.includes('server') && device.owned;
});
const settings = getSettings();
if (devices) {
await Promise.all(
devices.map(async (device) => {
await Promise.all(
device.connection.map(async (connection) => {
connection.host = connection.uri.replace(regexp, '$3');
let msg:
| { status: number; message: string }
| undefined = undefined;
const plexDeviceSettings = {
...settings.plex,
ip: connection.host,
port: connection.port,
useSsl: connection.protocol === 'https' ? true : false,
};
const plexClient = new PlexAPI({
plexToken: admin.plexToken,
plexSettings: plexDeviceSettings,
timeout: 5000,
});
try {
await plexClient.getStatus();
msg = {
status: 200,
message: 'OK',
};
} catch (e) {
msg = {
status: 500,
message: e.message,
};
}
connection.status = msg?.status;
connection.message = msg?.message;
})
);
})
);
}
return res.status(200).json(devices);
} catch (e) {
return next({
status: 500,
message: `Failed to connect to Plex: ${e.message}`,
});
}
});
settingsRoutes.get('/plex/library', async (req, res) => {
const settings = getSettings();
@ -156,270 +215,63 @@ settingsRoutes.get('/plex/sync', (req, res) => {
} else if (req.query.start) {
jobPlexFullSync.run();
}
return res.status(200).json(jobPlexFullSync.status());
});
settingsRoutes.get('/radarr', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.radarr);
});
settingsRoutes.post('/radarr', (req, res) => {
const settings = getSettings();
const newRadarr = req.body as RadarrSettings;
const lastItem = settings.radarr[settings.radarr.length - 1];
newRadarr.id = lastItem ? lastItem.id + 1 : 0;
// If we are setting this as the default, clear any previous defaults for the same type first
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
// and are the default
if (req.body.isDefault) {
settings.radarr
.filter((radarrInstance) => radarrInstance.is4k === req.body.is4k)
.forEach((radarrInstance) => {
radarrInstance.isDefault = false;
});
}
settings.radarr = [...settings.radarr, newRadarr];
settings.save();
return res.status(201).json(newRadarr);
});
settingsRoutes.post('/radarr/test', async (req, res, next) => {
try {
const radarr = new RadarrAPI({
apiKey: req.body.apiKey,
url: `${req.body.useSsl ? 'https' : 'http'}://${req.body.hostname}:${
req.body.port
}${req.body.baseUrl ?? ''}/api`,
});
const profiles = await radarr.getProfiles();
const folders = await radarr.getRootFolders();
return res.status(200).json({
profiles,
rootFolders: folders.map((folder) => ({
id: folder.id,
path: folder.path,
})),
});
} catch (e) {
logger.error('Failed to test Radarr', {
label: 'Radarr',
message: e.message,
});
next({ status: 500, message: 'Failed to connect to Radarr' });
}
});
settingsRoutes.put<{ id: string }>('/radarr/:id', (req, res) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (radarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
// If we are setting this as the default, clear any previous defaults for the same type first
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
// and are the default
if (req.body.isDefault) {
settings.radarr
.filter((radarrInstance) => radarrInstance.is4k === req.body.is4k)
.forEach((radarrInstance) => {
radarrInstance.isDefault = false;
});
}
settings.radarr[radarrIndex] = {
...req.body,
id: Number(req.params.id),
} as RadarrSettings;
settings.save();
return res.status(200).json(settings.radarr[radarrIndex]);
});
settingsRoutes.get<{ id: string }>('/radarr/:id/profiles', async (req, res) => {
const settings = getSettings();
const radarrSettings = settings.radarr.find(
(r) => r.id === Number(req.params.id)
);
if (!radarrSettings) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
url: `${radarrSettings.useSsl ? 'https' : 'http'}://${
radarrSettings.hostname
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`,
});
const profiles = await radarr.getProfiles();
settingsRoutes.get('/jobs', (_req, res) => {
return res.status(200).json(
profiles.map((profile) => ({
id: profile.id,
name: profile.name,
scheduledJobs.map((job) => ({
id: job.id,
name: job.name,
type: job.type,
nextExecutionTime: job.job.nextInvocation(),
running: job.running ? job.running() : false,
}))
);
});
settingsRoutes.delete<{ id: string }>('/radarr/:id', (req, res) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (radarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
const removed = settings.radarr.splice(radarrIndex, 1);
settings.save();
return res.status(200).json(removed[0]);
});
settingsRoutes.get('/sonarr', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.sonarr);
});
settingsRoutes.get<{ jobId: string }>('/jobs/:jobId/run', (req, res, next) => {
const scheduledJob = scheduledJobs.find((job) => job.id === req.params.jobId);
settingsRoutes.post('/sonarr', (req, res) => {
const settings = getSettings();
const newSonarr = req.body as SonarrSettings;
const lastItem = settings.sonarr[settings.sonarr.length - 1];
newSonarr.id = lastItem ? lastItem.id + 1 : 0;
// If we are setting this as the default, clear any previous defaults for the same type first
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
// and are the default
if (req.body.isDefault) {
settings.sonarr
.filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k)
.forEach((sonarrInstance) => {
sonarrInstance.isDefault = false;
});
if (!scheduledJob) {
return next({ status: 404, message: 'Job not found' });
}
settings.sonarr = [...settings.sonarr, newSonarr];
settings.save();
return res.status(201).json(newSonarr);
});
settingsRoutes.post('/sonarr/test', async (req, res, next) => {
try {
const sonarr = new SonarrAPI({
apiKey: req.body.apiKey,
url: `${req.body.useSsl ? 'https' : 'http'}://${req.body.hostname}:${
req.body.port
}${req.body.baseUrl ?? ''}/api`,
});
const profiles = await sonarr.getProfiles();
const folders = await sonarr.getRootFolders();
scheduledJob.job.invoke();
return res.status(200).json({
profiles,
rootFolders: folders.map((folder) => ({
id: folder.id,
path: folder.path,
})),
});
} catch (e) {
logger.error('Failed to test Sonarr', {
label: 'Sonarr',
message: e.message,
id: scheduledJob.id,
name: scheduledJob.name,
type: scheduledJob.type,
nextExecutionTime: scheduledJob.job.nextInvocation(),
running: scheduledJob.running ? scheduledJob.running() : false,
});
next({ status: 500, message: 'Failed to connect to Sonarr' });
}
});
settingsRoutes.put<{ id: string }>('/sonarr/:id', (req, res) => {
const settings = getSettings();
const sonarrIndex = settings.sonarr.findIndex(
(r) => r.id === Number(req.params.id)
settingsRoutes.get<{ jobId: string }>(
'/jobs/:jobId/cancel',
(req, res, next) => {
const scheduledJob = scheduledJobs.find(
(job) => job.id === req.params.jobId
);
if (sonarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
if (!scheduledJob) {
return next({ status: 404, message: 'Job not found' });
}
// If we are setting this as the default, clear any previous defaults for the same type first
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
// and are the default
if (req.body.isDefault) {
settings.sonarr
.filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k)
.forEach((sonarrInstance) => {
sonarrInstance.isDefault = false;
});
if (scheduledJob.cancelFn) {
scheduledJob.cancelFn();
}
settings.sonarr[sonarrIndex] = {
...req.body,
id: Number(req.params.id),
} as SonarrSettings;
settings.save();
return res.status(200).json(settings.sonarr[sonarrIndex]);
});
settingsRoutes.delete<{ id: string }>('/sonarr/:id', (req, res) => {
const settings = getSettings();
const sonarrIndex = settings.sonarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (sonarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
return res.status(200).json({
id: scheduledJob.id,
name: scheduledJob.name,
type: scheduledJob.type,
nextExecutionTime: scheduledJob.job.nextInvocation(),
running: scheduledJob.running ? scheduledJob.running() : false,
});
}
const removed = settings.sonarr.splice(sonarrIndex, 1);
settings.save();
return res.status(200).json(removed[0]);
});
settingsRoutes.get('/jobs', (_req, res) => {
return res.status(200).json(
scheduledJobs.map((job) => ({
name: job.name,
nextExecutionTime: job.job.nextInvocation(),
}))
);
});
);
settingsRoutes.get(
'/initialize',

@ -10,6 +10,29 @@ import WebhookAgent from '../../lib/notifications/agents/webhook';
const notificationRoutes = Router();
notificationRoutes.get('/', (_req, res) => {
const settings = getSettings().notifications;
return res.status(200).json({
enabled: settings.enabled,
autoapprovalEnabled: settings.autoapprovalEnabled,
});
});
notificationRoutes.post('/', (req, res) => {
const settings = getSettings();
Object.assign(settings.notifications, {
enabled: req.body.enabled,
autoapprovalEnabled: req.body.autoapprovalEnabled,
});
settings.save();
return res.status(200).json({
enabled: settings.notifications.enabled,
autoapprovalEnabled: settings.notifications.autoapprovalEnabled,
});
});
notificationRoutes.get('/discord', (_req, res) => {
const settings = getSettings();

@ -0,0 +1,149 @@
import { Router } from 'express';
import RadarrAPI from '../../api/radarr';
import { getSettings, RadarrSettings } from '../../lib/settings';
import logger from '../../logger';
const radarrRoutes = Router();
radarrRoutes.get('/', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.radarr);
});
radarrRoutes.post('/', (req, res) => {
const settings = getSettings();
const newRadarr = req.body as RadarrSettings;
const lastItem = settings.radarr[settings.radarr.length - 1];
newRadarr.id = lastItem ? lastItem.id + 1 : 0;
// If we are setting this as the default, clear any previous defaults for the same type first
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
// and are the default
if (req.body.isDefault) {
settings.radarr
.filter((radarrInstance) => radarrInstance.is4k === req.body.is4k)
.forEach((radarrInstance) => {
radarrInstance.isDefault = false;
});
}
settings.radarr = [...settings.radarr, newRadarr];
settings.save();
return res.status(201).json(newRadarr);
});
radarrRoutes.post('/test', async (req, res, next) => {
try {
const radarr = new RadarrAPI({
apiKey: req.body.apiKey,
url: `${req.body.useSsl ? 'https' : 'http'}://${req.body.hostname}:${
req.body.port
}${req.body.baseUrl ?? ''}/api`,
});
const profiles = await radarr.getProfiles();
const folders = await radarr.getRootFolders();
return res.status(200).json({
profiles,
rootFolders: folders.map((folder) => ({
id: folder.id,
path: folder.path,
})),
});
} catch (e) {
logger.error('Failed to test Radarr', {
label: 'Radarr',
message: e.message,
});
next({ status: 500, message: 'Failed to connect to Radarr' });
}
});
radarrRoutes.put<{ id: string }>('/:id', (req, res) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (radarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
// If we are setting this as the default, clear any previous defaults for the same type first
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
// and are the default
if (req.body.isDefault) {
settings.radarr
.filter((radarrInstance) => radarrInstance.is4k === req.body.is4k)
.forEach((radarrInstance) => {
radarrInstance.isDefault = false;
});
}
settings.radarr[radarrIndex] = {
...req.body,
id: Number(req.params.id),
} as RadarrSettings;
settings.save();
return res.status(200).json(settings.radarr[radarrIndex]);
});
radarrRoutes.get<{ id: string }>('/:id/profiles', async (req, res) => {
const settings = getSettings();
const radarrSettings = settings.radarr.find(
(r) => r.id === Number(req.params.id)
);
if (!radarrSettings) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
const radarr = new RadarrAPI({
apiKey: radarrSettings.apiKey,
url: `${radarrSettings.useSsl ? 'https' : 'http'}://${
radarrSettings.hostname
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`,
});
const profiles = await radarr.getProfiles();
return res.status(200).json(
profiles.map((profile) => ({
id: profile.id,
name: profile.name,
}))
);
});
radarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
const settings = getSettings();
const radarrIndex = settings.radarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (radarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
const removed = settings.radarr.splice(radarrIndex, 1);
settings.save();
return res.status(200).json(removed[0]);
});
export default radarrRoutes;

@ -0,0 +1,119 @@
import { Router } from 'express';
import SonarrAPI from '../../api/sonarr';
import { getSettings, SonarrSettings } from '../../lib/settings';
import logger from '../../logger';
const sonarrRoutes = Router();
sonarrRoutes.get('/', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.sonarr);
});
sonarrRoutes.post('/', (req, res) => {
const settings = getSettings();
const newSonarr = req.body as SonarrSettings;
const lastItem = settings.sonarr[settings.sonarr.length - 1];
newSonarr.id = lastItem ? lastItem.id + 1 : 0;
// If we are setting this as the default, clear any previous defaults for the same type first
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
// and are the default
if (req.body.isDefault) {
settings.sonarr
.filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k)
.forEach((sonarrInstance) => {
sonarrInstance.isDefault = false;
});
}
settings.sonarr = [...settings.sonarr, newSonarr];
settings.save();
return res.status(201).json(newSonarr);
});
sonarrRoutes.post('/test', async (req, res, next) => {
try {
const sonarr = new SonarrAPI({
apiKey: req.body.apiKey,
url: `${req.body.useSsl ? 'https' : 'http'}://${req.body.hostname}:${
req.body.port
}${req.body.baseUrl ?? ''}/api`,
});
const profiles = await sonarr.getProfiles();
const folders = await sonarr.getRootFolders();
return res.status(200).json({
profiles,
rootFolders: folders.map((folder) => ({
id: folder.id,
path: folder.path,
})),
});
} catch (e) {
logger.error('Failed to test Sonarr', {
label: 'Sonarr',
message: e.message,
});
next({ status: 500, message: 'Failed to connect to Sonarr' });
}
});
sonarrRoutes.put<{ id: string }>('/:id', (req, res) => {
const settings = getSettings();
const sonarrIndex = settings.sonarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (sonarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
// If we are setting this as the default, clear any previous defaults for the same type first
// ex: if is4k is true, it will only remove defaults for other servers that have is4k set to true
// and are the default
if (req.body.isDefault) {
settings.sonarr
.filter((sonarrInstance) => sonarrInstance.is4k === req.body.is4k)
.forEach((sonarrInstance) => {
sonarrInstance.isDefault = false;
});
}
settings.sonarr[sonarrIndex] = {
...req.body,
id: Number(req.params.id),
} as SonarrSettings;
settings.save();
return res.status(200).json(settings.sonarr[sonarrIndex]);
});
sonarrRoutes.delete<{ id: string }>('/:id', (req, res) => {
const settings = getSettings();
const sonarrIndex = settings.sonarr.findIndex(
(r) => r.id === Number(req.params.id)
);
if (sonarrIndex === -1) {
return res
.status(404)
.json({ status: '404', message: 'Settings instance not found' });
}
const removed = settings.sonarr.splice(sonarrIndex, 1);
settings.save();
return res.status(200).json(removed[0]);
});
export default sonarrRoutes;

@ -1,5 +1,5 @@
import { Router } from 'express';
import { getRepository } from 'typeorm';
import { getRepository, Not } from 'typeorm';
import PlexTvAPI from '../api/plextv';
import { MediaRequest } from '../entity/MediaRequest';
import { User } from '../entity/User';
@ -21,7 +21,7 @@ router.get('/', async (_req, res) => {
router.post('/', async (req, res, next) => {
try {
const settings = getSettings().notifications.agents.email;
const settings = getSettings();
const body = req.body;
const userRepository = getRepository(User);
@ -29,7 +29,7 @@ router.post('/', async (req, res, next) => {
const passedExplicitPassword = body.password && body.password.length > 0;
const avatar = gravatarUrl(body.email, { default: 'mm', size: 200 });
if (!passedExplicitPassword && !settings.enabled) {
if (!passedExplicitPassword && !settings.notifications.agents.email) {
throw new Error('Email notifications must be enabled');
}
@ -38,7 +38,7 @@ router.post('/', async (req, res, next) => {
username: body.username ?? body.email,
email: body.email,
password: body.password,
permissions: Permission.REQUEST,
permissions: settings.main.defaultPermissions,
plexToken: '',
userType: UserType.LOCAL,
});
@ -70,6 +70,51 @@ router.get<{ id: string }>('/:id', async (req, res, next) => {
}
});
const canMakePermissionsChange = (permissions: number, user?: User) =>
// Only let the owner grant admin privileges
!(hasPermission(Permission.ADMIN, permissions) && user?.id !== 1) ||
// Only let users with the manage settings permission, grant the same permission
!(
hasPermission(Permission.MANAGE_SETTINGS, permissions) &&
!hasPermission(Permission.MANAGE_SETTINGS, user?.permissions ?? 0)
);
router.put<
Record<string, never>,
Partial<User>[],
{ ids: string[]; permissions: number }
>('/', async (req, res, next) => {
try {
const isOwner = req.user?.id === 1;
if (!canMakePermissionsChange(req.body.permissions, req.user)) {
return next({
status: 403,
message: 'You do not have permission to grant this level of access',
});
}
const userRepository = getRepository(User);
const users = await userRepository.findByIds(req.body.ids, {
...(!isOwner ? { id: Not(1) } : {}),
});
const updatedUsers = await Promise.all(
users.map(async (user) => {
return userRepository.save(<User>{
...user,
...{ permissions: req.body.permissions },
});
})
);
return res.status(200).json(updatedUsers);
} catch (e) {
next({ status: 500, message: e.message });
}
});
router.put<{ id: string }>('/:id', async (req, res, next) => {
try {
const userRepository = getRepository(User);
@ -86,29 +131,18 @@ router.put<{ id: string }>('/:id', async (req, res, next) => {
});
}
// Only let the owner grant admin privileges
if (
hasPermission(Permission.ADMIN, req.body.permissions) &&
req.user?.id !== 1
) {
if (!canMakePermissionsChange(req.body.permissions, req.user)) {
return next({
status: 403,
message: 'You do not have permission to grant this level of access',
});
}
// Only let users with the manage settings permission, grant the same permission
if (
hasPermission(Permission.MANAGE_SETTINGS, req.body.permissions) &&
!hasPermission(Permission.MANAGE_SETTINGS, req.user?.permissions ?? 0)
) {
return next({
status: 403,
message: 'You do not have permission to grant this level of access',
Object.assign(user, {
username: req.body.username,
permissions: req.body.permissions,
});
}
Object.assign(user, req.body);
await userRepository.save(user);
return res.status(200).json(user.filter());
@ -183,20 +217,32 @@ router.post('/import-from-plex', async (req, res, next) => {
const createdUsers: User[] = [];
for (const rawUser of plexUsersResponse.MediaContainer.User) {
const account = rawUser.$;
const user = await userRepository.findOne({
where: { plexId: account.id },
where: [{ plexId: account.id }, { email: account.email }],
});
if (user) {
// Update the users avatar with their plex thumbnail (incase it changed)
user.avatar = account.thumb;
user.email = account.email;
user.username = account.username;
user.plexUsername = account.username;
// in-case the user was previously a local account
if (user.userType === UserType.LOCAL) {
user.userType = UserType.PLEX;
user.plexId = parseInt(account.id);
if (user.username === account.username) {
user.username = '';
}
}
await userRepository.save(user);
} else {
// Check to make sure it's a real account
if (account.email && account.username) {
const newUser = new User({
username: account.username,
plexUsername: account.username,
email: account.email,
permissions: settings.main.defaultPermissions,
plexId: parseInt(account.id),

@ -21,7 +21,7 @@ export class MediaSubscriber implements EntitySubscriberInterface {
if (entity.mediaType === MediaType.MOVIE) {
const requestRepository = getRepository(MediaRequest);
const relatedRequests = await requestRepository.find({
where: { media: entity },
where: { media: entity, is4k: false },
});
if (relatedRequests.length > 0) {
@ -64,7 +64,7 @@ export class MediaSubscriber implements EntitySubscriberInterface {
for (const changedSeasonNumber of changedSeasons) {
const requests = await requestRepository.find({
where: { media: entity },
where: { media: entity, is4k: false },
});
const request = requests.find(
(request) =>

@ -5,6 +5,7 @@ declare module 'plex-api' {
port: number;
token?: string;
https?: boolean;
timeout?: number;
authenticator: {
authenticate: (
_plexApi: PlexAPI,
@ -19,7 +20,7 @@ declare module 'plex-api' {
};
requestOptions?: Record<string, string | number>;
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
query: <T extends Record<string, any>>(endpoint: string) => Promise<T>;
}
}

@ -70,6 +70,9 @@ parts:
cp -R $SNAPCRAFT_PART_BUILD/node_modules $SNAPCRAFT_PART_INSTALL/
# Remove .github and gitbook as it will fail snap lint
rm -rf $SNAPCRAFT_PART_INSTALL/.github && rm $SNAPCRAFT_PART_INSTALL/.gitbook.yaml
stage-packages:
- on armhf:
- libatomic1
stage:
[ .next, ./* ]
prime:

@ -1,4 +1 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M43.5 0H0L43.5 43.5V0Z" fill="#31C48D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M36.707 9.29303C36.8945 9.48056 36.9998 9.73487 36.9998 10C36.9998 10.2652 36.8945 10.5195 36.707 10.707L28.707 18.707C28.5195 18.8945 28.2652 18.9998 28 18.9998C27.7348 18.9998 27.4805 18.8945 27.293 18.707L23.293 14.707C23.1108 14.5184 23.0101 14.2658 23.0123 14.0036C23.0146 13.7414 23.1198 13.4906 23.3052 13.3052C23.4906 13.1198 23.7414 13.0146 24.0036 13.0124C24.2658 13.0101 24.5184 13.1109 24.707 13.293L28 16.586L35.293 9.29303C35.4805 9.10556 35.7348 9.00024 36 9.00024C36.2652 9.00024 36.5195 9.10556 36.707 9.29303Z" fill="#F7FAFC"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" fill="none" viewBox="0 0 44 44"><path fill="#31C48D" d="M43.5 0H0L43.5 43.5V0Z"/><path fill="#F7FAFC" fill-rule="evenodd" d="M36.707 9.29303C36.8945 9.48056 36.9998 9.73487 36.9998 10C36.9998 10.2652 36.8945 10.5195 36.707 10.707L28.707 18.707C28.5195 18.8945 28.2652 18.9998 28 18.9998C27.7348 18.9998 27.4805 18.8945 27.293 18.707L23.293 14.707C23.1108 14.5184 23.0101 14.2658 23.0123 14.0036C23.0146 13.7414 23.1198 13.4906 23.3052 13.3052C23.4906 13.1198 23.7414 13.0146 24.0036 13.0124C24.2658 13.0101 24.5184 13.1109 24.707 13.293L28 16.586L35.293 9.29303C35.4805 9.10556 35.7348 9.00024 36 9.00024C36.2652 9.00024 36.5195 9.10556 36.707 9.29303Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 748 B

After

Width:  |  Height:  |  Size: 744 B

@ -1 +1 @@
<svg fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 257 B

After

Width:  |  Height:  |  Size: 250 B

@ -1 +1 @@
<svg fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 326 B

After

Width:  |  Height:  |  Size: 319 B

@ -1,6 +1 @@
<svg width="22" height="10" viewBox="0 0 22 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="22" height="10" rx="2" fill="#718096"/>
<circle cx="5" cy="5" r="2" fill="#E2E8F0"/>
<circle cx="11" cy="5" r="2" fill="#E2E8F0"/>
<circle cx="17" cy="5" r="2" fill="#E2E8F0"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="10" fill="none" viewBox="0 0 22 10"><rect width="22" height="10" fill="#718096" rx="2"/><circle cx="5" cy="5" r="2" fill="#E2E8F0"/><circle cx="11" cy="5" r="2" fill="#E2E8F0"/><circle cx="17" cy="5" r="2" fill="#E2E8F0"/></svg>

Before

Width:  |  Height:  |  Size: 293 B

After

Width:  |  Height:  |  Size: 287 B

@ -1 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="discord" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M297.216 243.2c0 15.616-11.52 28.416-26.112 28.416-14.336 0-26.112-12.8-26.112-28.416s11.52-28.416 26.112-28.416c14.592 0 26.112 12.8 26.112 28.416zm-119.552-28.416c-14.592 0-26.112 12.8-26.112 28.416s11.776 28.416 26.112 28.416c14.592 0 26.112-12.8 26.112-28.416.256-15.616-11.52-28.416-26.112-28.416zM448 52.736V512c-64.494-56.994-43.868-38.128-118.784-107.776l13.568 47.36H52.48C23.552 451.584 0 428.032 0 398.848V52.736C0 23.552 23.552 0 52.48 0h343.04C424.448 0 448 23.552 448 52.736zm-72.96 242.688c0-82.432-36.864-149.248-36.864-149.248-36.864-27.648-71.936-26.88-71.936-26.88l-3.584 4.096c43.52 13.312 63.744 32.512 63.744 32.512-60.811-33.329-132.244-33.335-191.232-7.424-9.472 4.352-15.104 7.424-15.104 7.424s21.248-20.224 67.328-33.536l-2.56-3.072s-35.072-.768-71.936 26.88c0 0-36.864 66.816-36.864 149.248 0 0 21.504 37.12 78.08 38.912 0 0 9.472-11.52 17.152-21.248-32.512-9.728-44.8-30.208-44.8-30.208 3.766 2.636 9.976 6.053 10.496 6.4 43.21 24.198 104.588 32.126 159.744 8.96 8.96-3.328 18.944-8.192 29.44-15.104 0 0-12.8 20.992-46.336 30.464 7.68 9.728 16.896 20.736 16.896 20.736 56.576-1.792 78.336-38.912 78.336-38.912z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" data-icon="discord" data-prefix="fab" focusable="false" role="img" viewBox="0 0 448 512"><path fill="currentColor" d="M297.216 243.2c0 15.616-11.52 28.416-26.112 28.416-14.336 0-26.112-12.8-26.112-28.416s11.52-28.416 26.112-28.416c14.592 0 26.112 12.8 26.112 28.416zm-119.552-28.416c-14.592 0-26.112 12.8-26.112 28.416s11.776 28.416 26.112 28.416c14.592 0 26.112-12.8 26.112-28.416.256-15.616-11.52-28.416-26.112-28.416zM448 52.736V512c-64.494-56.994-43.868-38.128-118.784-107.776l13.568 47.36H52.48C23.552 451.584 0 428.032 0 398.848V52.736C0 23.552 23.552 0 52.48 0h343.04C424.448 0 448 23.552 448 52.736zm-72.96 242.688c0-82.432-36.864-149.248-36.864-149.248-36.864-27.648-71.936-26.88-71.936-26.88l-3.584 4.096c43.52 13.312 63.744 32.512 63.744 32.512-60.811-33.329-132.244-33.335-191.232-7.424-9.472 4.352-15.104 7.424-15.104 7.424s21.248-20.224 67.328-33.536l-2.56-3.072s-35.072-.768-71.936 26.88c0 0-36.864 66.816-36.864 149.248 0 0 21.504 37.12 78.08 38.912 0 0 9.472-11.52 17.152-21.248-32.512-9.728-44.8-30.208-44.8-30.208 3.766 2.636 9.976 6.053 10.496 6.4 43.21 24.198 104.588 32.126 159.744 8.96 8.96-3.328 18.944-8.192 29.44-15.104 0 0-12.8 20.992-46.336 30.464 7.68 9.728 16.896 20.736 16.896 20.736 56.576-1.792 78.336-38.912 78.336-38.912z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

@ -1,6 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="57 57 602 602" version="1.1">
<g id="layer1" stroke="none" stroke-width="1" fill="rgb(31, 41, 55)" fill-rule="evenodd" transform="translate(58.964119, 58.887520)" opacity="0.91">
<ellipse style="fill: rgb(255, 255, 255); fill-rule: evenodd; stroke: rgb(255, 255, 255); stroke-width: 0;" transform="matrix(-0.674571, 0.73821, -0.73821, -0.674571, 556.833239, 241.613465)" cx="216.308" cy="152.076" rx="296.855" ry="296.855"></ellipse>
<path d="M 280.949 172.514 L 355.429 162.714 L 282.909 326.374 L 282.909 326.374 C 295.649 325.394 308.142 321.067 320.389 313.394 L 320.389 313.394 L 320.389 313.394 C 332.642 305.714 343.916 296.077 354.209 284.484 L 354.209 284.484 L 354.209 284.484 C 364.496 272.884 373.396 259.981 380.909 245.774 L 380.909 245.774 L 380.909 245.774 C 388.422 231.561 393.812 217.594 397.079 203.874 L 397.079 203.874 L 397.079 203.874 C 399.039 195.381 399.939 187.214 399.779 179.374 L 399.779 179.374 L 399.779 179.374 C 399.612 171.534 397.569 164.674 393.649 158.794 L 393.649 158.794 L 393.649 158.794 C 389.729 152.914 383.766 148.177 375.759 144.584 L 375.759 144.584 L 375.759 144.584 C 367.759 140.991 356.899 139.194 343.179 139.194 L 343.179 139.194 L 343.179 139.194 C 327.172 139.194 311.409 141.807 295.889 147.034 L 295.889 147.034 L 295.889 147.034 C 280.376 152.261 266.002 159.857 252.769 169.824 L 252.769 169.824 L 252.769 169.824 C 239.542 179.784 228.029 192.197 218.229 207.064 L 218.229 207.064 L 218.229 207.064 C 208.429 221.924 201.406 238.827 197.159 257.774 L 197.159 257.774 L 197.159 257.774 C 195.526 263.981 194.546 268.961 194.219 272.714 L 194.219 272.714 L 194.219 272.714 C 193.892 276.474 193.812 279.577 193.979 282.024 L 193.979 282.024 L 193.979 282.024 C 194.139 284.477 194.462 286.357 194.949 287.664 L 194.949 287.664 L 194.949 287.664 C 195.442 288.971 195.852 290.277 196.179 291.584 L 196.179 291.584 L 196.179 291.584 C 179.519 291.584 167.349 288.234 159.669 281.534 L 159.669 281.534 L 159.669 281.534 C 151.996 274.841 150.119 263.164 154.039 246.504 L 154.039 246.504 L 154.039 246.504 C 157.959 229.191 166.862 212.694 180.749 197.014 L 180.749 197.014 L 180.749 197.014 C 194.629 181.334 211.122 167.531 230.229 155.604 L 230.229 155.604 L 230.229 155.604 C 249.342 143.684 270.249 134.214 292.949 127.194 L 292.949 127.194 L 292.949 127.194 C 315.656 120.167 337.789 116.654 359.349 116.654 L 359.349 116.654 L 359.349 116.654 C 378.296 116.654 394.219 119.347 407.119 124.734 L 407.119 124.734 L 407.119 124.734 C 420.026 130.127 430.072 137.234 437.259 146.054 L 437.259 146.054 L 437.259 146.054 C 444.446 154.874 448.936 165.164 450.729 176.924 L 450.729 176.924 L 450.729 176.924 C 452.529 188.684 451.959 200.934 449.019 213.674 L 449.019 213.674 L 449.019 213.674 C 445.426 229.027 438.646 244.464 428.679 259.984 L 428.679 259.984 L 428.679 259.984 C 418.719 275.497 406.226 289.544 391.199 302.124 L 391.199 302.124 L 391.199 302.124 C 376.172 314.697 358.939 324.904 339.499 332.744 L 339.499 332.744 L 339.499 332.744 C 320.066 340.584 299.406 344.504 277.519 344.504 L 277.519 344.504 L 275.069 344.504 L 212.839 484.154 L 142.279 484.154 L 280.949 172.514 Z" transform="matrix(1, 0, 0, 1, 0, 0)" style="fill-rule: nonzero; white-space: pre;"></path>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="57 57 602 602"><g id="layer1" fill="#1F2937" fill-rule="evenodd" stroke="none" stroke-width="1" opacity=".91" transform="translate(58.964119, 58.887520)"><ellipse style="fill:#fff;fill-rule:evenodd;stroke:#fff;stroke-width:0" cx="216.308" cy="152.076" rx="296.855" ry="296.855" transform="matrix(-0.674571, 0.73821, -0.73821, -0.674571, 556.833239, 241.613465)"/><path d="M 280.949 172.514 L 355.429 162.714 L 282.909 326.374 L 282.909 326.374 C 295.649 325.394 308.142 321.067 320.389 313.394 L 320.389 313.394 L 320.389 313.394 C 332.642 305.714 343.916 296.077 354.209 284.484 L 354.209 284.484 L 354.209 284.484 C 364.496 272.884 373.396 259.981 380.909 245.774 L 380.909 245.774 L 380.909 245.774 C 388.422 231.561 393.812 217.594 397.079 203.874 L 397.079 203.874 L 397.079 203.874 C 399.039 195.381 399.939 187.214 399.779 179.374 L 399.779 179.374 L 399.779 179.374 C 399.612 171.534 397.569 164.674 393.649 158.794 L 393.649 158.794 L 393.649 158.794 C 389.729 152.914 383.766 148.177 375.759 144.584 L 375.759 144.584 L 375.759 144.584 C 367.759 140.991 356.899 139.194 343.179 139.194 L 343.179 139.194 L 343.179 139.194 C 327.172 139.194 311.409 141.807 295.889 147.034 L 295.889 147.034 L 295.889 147.034 C 280.376 152.261 266.002 159.857 252.769 169.824 L 252.769 169.824 L 252.769 169.824 C 239.542 179.784 228.029 192.197 218.229 207.064 L 218.229 207.064 L 218.229 207.064 C 208.429 221.924 201.406 238.827 197.159 257.774 L 197.159 257.774 L 197.159 257.774 C 195.526 263.981 194.546 268.961 194.219 272.714 L 194.219 272.714 L 194.219 272.714 C 193.892 276.474 193.812 279.577 193.979 282.024 L 193.979 282.024 L 193.979 282.024 C 194.139 284.477 194.462 286.357 194.949 287.664 L 194.949 287.664 L 194.949 287.664 C 195.442 288.971 195.852 290.277 196.179 291.584 L 196.179 291.584 L 196.179 291.584 C 179.519 291.584 167.349 288.234 159.669 281.534 L 159.669 281.534 L 159.669 281.534 C 151.996 274.841 150.119 263.164 154.039 246.504 L 154.039 246.504 L 154.039 246.504 C 157.959 229.191 166.862 212.694 180.749 197.014 L 180.749 197.014 L 180.749 197.014 C 194.629 181.334 211.122 167.531 230.229 155.604 L 230.229 155.604 L 230.229 155.604 C 249.342 143.684 270.249 134.214 292.949 127.194 L 292.949 127.194 L 292.949 127.194 C 315.656 120.167 337.789 116.654 359.349 116.654 L 359.349 116.654 L 359.349 116.654 C 378.296 116.654 394.219 119.347 407.119 124.734 L 407.119 124.734 L 407.119 124.734 C 420.026 130.127 430.072 137.234 437.259 146.054 L 437.259 146.054 L 437.259 146.054 C 444.446 154.874 448.936 165.164 450.729 176.924 L 450.729 176.924 L 450.729 176.924 C 452.529 188.684 451.959 200.934 449.019 213.674 L 449.019 213.674 L 449.019 213.674 C 445.426 229.027 438.646 244.464 428.679 259.984 L 428.679 259.984 L 428.679 259.984 C 418.719 275.497 406.226 289.544 391.199 302.124 L 391.199 302.124 L 391.199 302.124 C 376.172 314.697 358.939 324.904 339.499 332.744 L 339.499 332.744 L 339.499 332.744 C 320.066 340.584 299.406 344.504 277.519 344.504 L 277.519 344.504 L 275.069 344.504 L 212.839 484.154 L 142.279 484.154 L 280.949 172.514 Z" transform="matrix(1, 0, 0, 1, 0, 0)" style="fill-rule:nonzero;white-space:pre"/></g></svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

@ -1 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fab" data-icon="slack" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M94.12 315.1c0 25.9-21.16 47.06-47.06 47.06S0 341 0 315.1c0-25.9 21.16-47.06 47.06-47.06h47.06v47.06zm23.72 0c0-25.9 21.16-47.06 47.06-47.06s47.06 21.16 47.06 47.06v117.84c0 25.9-21.16 47.06-47.06 47.06s-47.06-21.16-47.06-47.06V315.1zm47.06-188.98c-25.9 0-47.06-21.16-47.06-47.06S139 32 164.9 32s47.06 21.16 47.06 47.06v47.06H164.9zm0 23.72c25.9 0 47.06 21.16 47.06 47.06s-21.16 47.06-47.06 47.06H47.06C21.16 243.96 0 222.8 0 196.9s21.16-47.06 47.06-47.06H164.9zm188.98 47.06c0-25.9 21.16-47.06 47.06-47.06 25.9 0 47.06 21.16 47.06 47.06s-21.16 47.06-47.06 47.06h-47.06V196.9zm-23.72 0c0 25.9-21.16 47.06-47.06 47.06-25.9 0-47.06-21.16-47.06-47.06V79.06c0-25.9 21.16-47.06 47.06-47.06 25.9 0 47.06 21.16 47.06 47.06V196.9zM283.1 385.88c25.9 0 47.06 21.16 47.06 47.06 0 25.9-21.16 47.06-47.06 47.06-25.9 0-47.06-21.16-47.06-47.06v-47.06h47.06zm0-23.72c-25.9 0-47.06-21.16-47.06-47.06 0-25.9 21.16-47.06 47.06-47.06h117.84c25.9 0 47.06 21.16 47.06 47.06 0 25.9-21.16 47.06-47.06 47.06H283.1z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" data-icon="slack" data-prefix="fab" focusable="false" role="img" viewBox="0 0 448 512"><path fill="currentColor" d="M94.12 315.1c0 25.9-21.16 47.06-47.06 47.06S0 341 0 315.1c0-25.9 21.16-47.06 47.06-47.06h47.06v47.06zm23.72 0c0-25.9 21.16-47.06 47.06-47.06s47.06 21.16 47.06 47.06v117.84c0 25.9-21.16 47.06-47.06 47.06s-47.06-21.16-47.06-47.06V315.1zm47.06-188.98c-25.9 0-47.06-21.16-47.06-47.06S139 32 164.9 32s47.06 21.16 47.06 47.06v47.06H164.9zm0 23.72c25.9 0 47.06 21.16 47.06 47.06s-21.16 47.06-47.06 47.06H47.06C21.16 243.96 0 222.8 0 196.9s21.16-47.06 47.06-47.06H164.9zm188.98 47.06c0-25.9 21.16-47.06 47.06-47.06 25.9 0 47.06 21.16 47.06 47.06s-21.16 47.06-47.06 47.06h-47.06V196.9zm-23.72 0c0 25.9-21.16 47.06-47.06 47.06-25.9 0-47.06-21.16-47.06-47.06V79.06c0-25.9 21.16-47.06 47.06-47.06 25.9 0 47.06 21.16 47.06 47.06V196.9zM283.1 385.88c25.9 0 47.06 21.16 47.06 47.06 0 25.9-21.16 47.06-47.06 47.06-25.9 0-47.06-21.16-47.06-47.06v-47.06h47.06zm0-23.72c-25.9 0-47.06-21.16-47.06-47.06 0-25.9 21.16-47.06 47.06-47.06h117.84c25.9 0 47.06 21.16 47.06 47.06 0 25.9-21.16 47.06-47.06 47.06H283.1z"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

@ -1,3 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 24C0 37.2548 10.7452 48 24 48C37.2548 48 48 37.2548 48 24C48 10.7452 37.2548 0 24 0C10.7452 0 0 10.7452 0 24ZM19.6 35L20.0083 28.8823L20.008 28.882L31.1369 18.839C31.6253 18.4055 31.0303 18.1941 30.3819 18.5873L16.6473 27.2523L10.7147 25.4007C9.4335 25.0084 9.4243 24.128 11.0023 23.4951L34.1203 14.5809C35.1762 14.1015 36.1953 14.8345 35.7922 16.4505L31.8552 35.0031C31.5803 36.3215 30.7837 36.6368 29.68 36.0278L23.6827 31.5969L20.8 34.4C20.7909 34.4088 20.7819 34.4176 20.7729 34.4264C20.4505 34.7403 20.1837 35 19.6 35Z" fill="currentColor"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 48 48"><path fill="currentColor" fill-rule="evenodd" d="M0 24C0 37.2548 10.7452 48 24 48C37.2548 48 48 37.2548 48 24C48 10.7452 37.2548 0 24 0C10.7452 0 0 10.7452 0 24ZM19.6 35L20.0083 28.8823L20.008 28.882L31.1369 18.839C31.6253 18.4055 31.0303 18.1941 30.3819 18.5873L16.6473 27.2523L10.7147 25.4007C9.4335 25.0084 9.4243 24.128 11.0023 23.4951L34.1203 14.5809C35.1762 14.1015 36.1953 14.8345 35.7922 16.4505L31.8552 35.0031C31.5803 36.3215 30.7837 36.6368 29.68 36.0278L23.6827 31.5969L20.8 34.4C20.7909 34.4088 20.7819 34.4176 20.7729 34.4264C20.4505 34.7403 20.1837 35 19.6 35Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 679 B

After

Width:  |  Height:  |  Size: 676 B

@ -1,4 +1 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M43.5 0H0L43.5 43.5V0Z" fill="#ED8936"/>
<path d="M31 5.79999C29.5678 5.79999 28.1943 6.36891 27.1816 7.38161C26.1689 8.39431 25.6 9.76782 25.6 11.2V14.4274L24.9637 15.0637C24.8379 15.1896 24.7522 15.3499 24.7175 15.5245C24.6828 15.699 24.7006 15.8799 24.7687 16.0444C24.8368 16.2088 24.9521 16.3494 25.1001 16.4482C25.2481 16.5471 25.422 16.5999 25.6 16.6H36.4C36.578 16.5999 36.7519 16.5471 36.8999 16.4482C37.0479 16.3494 37.1632 16.2088 37.2313 16.0444C37.2994 15.8799 37.3172 15.699 37.2825 15.5245C37.2478 15.3499 37.1621 15.1896 37.0363 15.0637L36.4 14.4274V11.2C36.4 9.76782 35.8311 8.39431 34.8184 7.38161C33.8057 6.36891 32.4322 5.79999 31 5.79999ZM31 20.2C30.2839 20.2 29.5972 19.9155 29.0908 19.4092C28.5845 18.9028 28.3 18.2161 28.3 17.5H33.7C33.7 18.2161 33.4155 18.9028 32.9092 19.4092C32.4028 19.9155 31.7161 20.2 31 20.2Z" fill="white"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" fill="none" viewBox="0 0 44 44"><path fill="#ED8936" d="M43.5 0H0L43.5 43.5V0Z"/><path fill="#fff" d="M31 5.79999C29.5678 5.79999 28.1943 6.36891 27.1816 7.38161C26.1689 8.39431 25.6 9.76782 25.6 11.2V14.4274L24.9637 15.0637C24.8379 15.1896 24.7522 15.3499 24.7175 15.5245C24.6828 15.699 24.7006 15.8799 24.7687 16.0444C24.8368 16.2088 24.9521 16.3494 25.1001 16.4482C25.2481 16.5471 25.422 16.5999 25.6 16.6H36.4C36.578 16.5999 36.7519 16.5471 36.8999 16.4482C37.0479 16.3494 37.1632 16.2088 37.2313 16.0444C37.2994 15.8799 37.3172 15.699 37.2825 15.5245C37.2478 15.3499 37.1621 15.1896 37.0363 15.0637L36.4 14.4274V11.2C36.4 9.76782 35.8311 8.39431 34.8184 7.38161C33.8057 6.36891 32.4322 5.79999 31 5.79999ZM31 20.2C30.2839 20.2 29.5972 19.9155 29.0908 19.4092C28.5845 18.9028 28.3 18.2161 28.3 17.5H33.7C33.7 18.2161 33.4155 18.9028 32.9092 19.4092C32.4028 19.9155 31.7161 20.2 31 20.2Z"/></svg>

Before

Width:  |  Height:  |  Size: 967 B

After

Width:  |  Height:  |  Size: 962 B

@ -1,3 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid meet" viewBox="0 0 575 289.83"><defs><path d="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z" id="d1pwhf9wy2"></path><path d="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z" id="g5jjnq26yS"></path><path d="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z" id="i3Prh1JpXt"></path><path d="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z" id="a4ov9rRGQm"></path><path d="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z" id="fk968BpsX"></path></defs><g><g><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill="#f6c700" fill-opacity="1"></use><g><use xlink:href="#d1pwhf9wy2" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#g5jjnq26yS" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#g5jjnq26yS" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#i3Prh1JpXt" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#a4ov9rRGQm" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g><g><use xlink:href="#fk968BpsX" opacity="1" fill="#000000" fill-opacity="1"></use><g><use xlink:href="#fk968BpsX" opacity="1" fill-opacity="0" stroke="#000000" stroke-width="1" stroke-opacity="0"></use></g></g></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid meet" version="1.1" viewBox="0 0 575 289.83"><defs><path id="d1pwhf9wy2" d="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z"/><path id="g5jjnq26yS" d="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z"/><path id="i3Prh1JpXt" d="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z"/><path id="a4ov9rRGQm" d="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z"/><path id="fk968BpsX" d="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z"/></defs><g><g><g><use fill="#f6c700" fill-opacity="1" opacity="1" xlink:href="#d1pwhf9wy2"/><g><use fill-opacity="0" stroke="#000" stroke-opacity="0" stroke-width="1" opacity="1" xlink:href="#d1pwhf9wy2"/></g></g><g><use fill="#000" fill-opacity="1" opacity="1" xlink:href="#g5jjnq26yS"/><g><use fill-opacity="0" stroke="#000" stroke-opacity="0" stroke-width="1" opacity="1" xlink:href="#g5jjnq26yS"/></g></g><g><use fill="#000" fill-opacity="1" opacity="1" xlink:href="#i3Prh1JpXt"/><g><use fill-opacity="0" stroke="#000" stroke-opacity="0" stroke-width="1" opacity="1" xlink:href="#i3Prh1JpXt"/></g></g><g><use fill="#000" fill-opacity="1" opacity="1" xlink:href="#a4ov9rRGQm"/><g><use fill-opacity="0" stroke="#000" stroke-opacity="0" stroke-width="1" opacity="1" xlink:href="#a4ov9rRGQm"/></g></g><g><use fill="#000" fill-opacity="1" opacity="1" xlink:href="#fk968BpsX"/><g><use fill-opacity="0" stroke="#000" stroke-opacity="0" stroke-width="1" opacity="1" xlink:href="#fk968BpsX"/></g></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

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

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 185.04 133.4"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" y1="66.7" x2="185.04" y2="66.7" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset="0.56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 4</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z"/></g></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 185.04 133.4"><defs><style>.cls-1{fill:url(#linear-gradient)}</style><linearGradient id="linear-gradient" x2="185.04" y1="66.7" y2="66.7" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#90cea1"/><stop offset=".56" stop-color="#3cbec9"/><stop offset="1" stop-color="#00b3e5"/></linearGradient></defs><title>Asset 4</title><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path d="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z" class="cls-1"/></g></g></svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

@ -0,0 +1,21 @@
<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>

After

Width:  |  Height:  |  Size: 524 B

@ -1,4 +1 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M43.5 0H0L43.5 43.5V0Z" fill="#667EEA"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M31 20.2C32.9096 20.2 34.7409 19.4414 36.0912 18.0912C37.4414 16.7409 38.2 14.9095 38.2 13C38.2 11.0904 37.4414 9.25908 36.0912 7.90882C34.7409 6.55856 32.9096 5.79999 31 5.79999C29.0904 5.79999 27.2591 6.55856 25.9088 7.90882C24.5586 9.25908 23.8 11.0904 23.8 13C23.8 14.9095 24.5586 16.7409 25.9088 18.0912C27.2591 19.4414 29.0904 20.2 31 20.2ZM31.9 9.39999C31.9 9.16129 31.8052 8.93237 31.6364 8.76359C31.4676 8.59481 31.2387 8.49999 31 8.49999C30.7613 8.49999 30.5324 8.59481 30.3636 8.76359C30.1948 8.93237 30.1 9.16129 30.1 9.39999V13C30.1 13.2387 30.1949 13.4675 30.3637 13.6363L32.9089 16.1824C32.9925 16.266 33.0918 16.3323 33.201 16.3776C33.3103 16.4228 33.4274 16.4461 33.5456 16.4461C33.6639 16.4461 33.781 16.4228 33.8903 16.3776C33.9995 16.3323 34.0988 16.266 34.1824 16.1824C34.266 16.0988 34.3323 15.9995 34.3776 15.8902C34.4229 15.781 34.4461 15.6639 34.4461 15.5456C34.4461 15.4274 34.4229 15.3103 34.3776 15.201C34.3323 15.0918 34.266 14.9925 34.1824 14.9089L31.9 12.6274V9.39999Z" fill="white"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" fill="none" viewBox="0 0 44 44"><path fill="#667EEA" d="M43.5 0H0L43.5 43.5V0Z"/><path fill="#fff" fill-rule="evenodd" d="M31 20.2C32.9096 20.2 34.7409 19.4414 36.0912 18.0912C37.4414 16.7409 38.2 14.9095 38.2 13C38.2 11.0904 37.4414 9.25908 36.0912 7.90882C34.7409 6.55856 32.9096 5.79999 31 5.79999C29.0904 5.79999 27.2591 6.55856 25.9088 7.90882C24.5586 9.25908 23.8 11.0904 23.8 13C23.8 14.9095 24.5586 16.7409 25.9088 18.0912C27.2591 19.4414 29.0904 20.2 31 20.2ZM31.9 9.39999C31.9 9.16129 31.8052 8.93237 31.6364 8.76359C31.4676 8.59481 31.2387 8.49999 31 8.49999C30.7613 8.49999 30.5324 8.59481 30.3636 8.76359C30.1948 8.93237 30.1 9.16129 30.1 9.39999V13C30.1 13.2387 30.1949 13.4675 30.3637 13.6363L32.9089 16.1824C32.9925 16.266 33.0918 16.3323 33.201 16.3776C33.3103 16.4228 33.4274 16.4461 33.5456 16.4461C33.6639 16.4461 33.781 16.4228 33.8903 16.3776C33.9995 16.3323 34.0988 16.266 34.1824 16.1824C34.266 16.0988 34.3323 15.9995 34.3776 15.8902C34.4229 15.781 34.4461 15.6639 34.4461 15.5456C34.4461 15.4274 34.4229 15.3103 34.3776 15.201C34.3323 15.0918 34.266 14.9925 34.1824 14.9089L31.9 12.6274V9.39999Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

@ -1 +1 @@
<svg class="w-6 h-6" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" stroke="currentColor" class="w-6 h-6" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/></svg>

Before

Width:  |  Height:  |  Size: 291 B

After

Width:  |  Height:  |  Size: 284 B

@ -1 +1 @@
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" class="w-6 h-6" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>

Before

Width:  |  Height:  |  Size: 267 B

After

Width:  |  Height:  |  Size: 260 B

@ -20,7 +20,7 @@ import TitleCard from '../TitleCard';
import Transition from '../Transition';
const messages = defineMessages({
overviewunavailable: 'Overview unavailable',
overviewunavailable: 'Overview unavailable.',
overview: 'Overview',
movies: 'Movies',
numberofmovies: 'Number of Movies: {count}',

@ -0,0 +1,67 @@
import * as React from 'react';
import { useState } from 'react';
import AnimateHeight from 'react-animate-height';
export interface AccordionProps {
children: (args: AccordionChildProps) => React.ReactElement<any, any> | null;
/** If true, only one accordion item can be open at any time */
single?: boolean;
/** If true, at least one accordion item will always be open */
atLeastOne?: boolean;
initialOpenIndexes?: number[];
}
export interface AccordionChildProps {
openIndexes: number[];
handleClick(index: number): void;
AccordionContent: any;
}
export const AccordionContent: React.FC<{ isOpen: boolean }> = ({
isOpen,
children,
}) => {
return <AnimateHeight height={isOpen ? 'auto' : 0}>{children}</AnimateHeight>;
};
const Accordion: React.FC<AccordionProps> = ({
single,
atLeastOne,
initialOpenIndexes,
children,
}) => {
const initialState = initialOpenIndexes || (atLeastOne && [0]) || [];
const [openIndexes, setOpenIndexes] = useState<number[]>(initialState);
const close = (index: number) => {
const openCount = openIndexes.length;
const newListOfIndexes =
atLeastOne && openCount === 1 && openIndexes.includes(index)
? openIndexes
: openIndexes.filter((i) => i !== index);
setOpenIndexes(newListOfIndexes);
};
const open = (index: number) => {
const newListOfIndexes = single ? [index] : [...openIndexes, index];
setOpenIndexes(newListOfIndexes);
};
const handleItemClick = (index: number) => {
const action = openIndexes.includes(index) ? 'closing' : 'opening';
if (action === 'closing') {
close(index);
} else {
open(index);
}
};
return children({
openIndexes: openIndexes,
handleClick: handleItemClick,
AccordionContent,
});
};
export default Accordion;

@ -2,7 +2,7 @@ import React from 'react';
interface AlertProps {
title: string;
type?: 'warning' | 'info';
type?: 'warning' | 'info' | 'error';
}
const Alert: React.FC<AlertProps> = ({ title, children, type }) => {
@ -51,6 +51,29 @@ const Alert: React.FC<AlertProps> = ({ title, children, type }) => {
),
};
break;
case 'error':
design = {
bgColor: 'bg-red-600',
titleColor: 'text-red-200',
textColor: 'text-red-300',
svg: (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
};
break;
}
return (

@ -2,11 +2,16 @@ import React from 'react';
interface BadgeProps {
badgeType?: 'default' | 'primary' | 'danger' | 'warning' | 'success';
className?: string;
}
const Badge: React.FC<BadgeProps> = ({ badgeType = 'default', children }) => {
const Badge: React.FC<BadgeProps> = ({
badgeType = 'default',
className,
children,
}) => {
const badgeStyle = [
'px-2 inline-flex text-xs leading-5 font-semibold rounded-full',
'px-2 inline-flex text-xs leading-5 font-semibold rounded-full cursor-default',
];
switch (badgeType) {
@ -17,12 +22,16 @@ const Badge: React.FC<BadgeProps> = ({ badgeType = 'default', children }) => {
badgeStyle.push('bg-yellow-500 text-yellow-100');
break;
case 'success':
badgeStyle.push('bg-green-400 text-green-100');
badgeStyle.push('bg-green-500 text-green-100');
break;
default:
badgeStyle.push('bg-indigo-500 text-indigo-100');
}
if (className) {
badgeStyle.push(className);
}
return <span className={badgeStyle.join(' ')}>{children}</span>;
};

@ -9,22 +9,41 @@ import useClickOutside from '../../../hooks/useClickOutside';
import Transition from '../../Transition';
import { withProperties } from '../../../utils/typeHelpers';
const DropdownItem: React.FC<AnchorHTMLAttributes<HTMLAnchorElement>> = ({
interface DropdownItemProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
buttonType?: 'primary' | 'ghost';
}
const DropdownItem: React.FC<DropdownItemProps> = ({
children,
buttonType = 'primary',
...props
}) => (
}) => {
let styleClass = '';
switch (buttonType) {
case 'ghost':
styleClass =
'text-white bg-gray-700 hover:bg-gray-600 hover:text-white focus:border-gray-500 focus:text-white';
break;
default:
styleClass =
'text-white bg-indigo-600 hover:bg-indigo-500 hover:text-white focus:border-indigo-700 focus:text-white';
}
return (
<a
className="flex items-center px-4 py-2 text-sm leading-5 text-white bg-indigo-600 cursor-pointer hover:bg-indigo-500 hover:text-white focus:outline-none focus:border-indigo-700 focus:text-white"
className={`flex items-center px-4 py-2 text-sm leading-5 cursor-pointer focus:outline-none ${styleClass}`}
{...props}
>
{children}
</a>
);
);
};
interface ButtonWithDropdownProps
extends ButtonHTMLAttributes<HTMLButtonElement> {
text: ReactNode;
dropdownIcon?: ReactNode;
buttonType?: 'primary' | 'ghost';
}
const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
@ -32,29 +51,52 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
children,
dropdownIcon,
className,
buttonType = 'primary',
...props
}) => {
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
useClickOutside(buttonRef, () => setIsOpen(false));
const styleClasses = {
mainButtonClasses: '',
dropdownSideButtonClasses: '',
dropdownClasses: '',
};
switch (buttonType) {
case 'ghost':
styleClasses.mainButtonClasses =
'text-white bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
styleClasses.dropdownSideButtonClasses =
'bg-transparent border border-gray-600 hover:border-gray-200 focus:border-gray-100 active:border-gray-100';
styleClasses.dropdownClasses = 'bg-gray-700';
break;
default:
styleClasses.mainButtonClasses =
'text-white bg-indigo-600 hover:text-white hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue';
styleClasses.dropdownSideButtonClasses =
'bg-indigo-700 border border-indigo-600 hover:bg-indigo-500 active:bg-indigo-700 focus:ring-blue';
styleClasses.dropdownClasses = 'bg-indigo-600';
}
return (
<span className="relative z-0 inline-flex h-full rounded-md shadow-sm">
<button
type="button"
className={`relative inline-flex h-full items-center px-4 py-2 text-white bg-indigo-600 hover:bg-indigo-500 text-sm leading-5 font-medium hover:text-white focus:ring-indigo active:bg-indigo-700 focus:z-10 focus:outline-none focus:ring-blue transition ease-in-out duration-150 ${
children ? 'rounded-l-md' : 'rounded-md'
} ${className}`}
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 ${
styleClasses.mainButtonClasses
} ${children ? 'rounded-l-md' : 'rounded-md'} ${className}`}
ref={buttonRef}
{...props}
>
{text}
</button>
<span className="relative block -ml-px">
<span className="relative z-10 block -ml-px">
{children && (
<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 bg-indigo-700 border border-indigo-600 rounded-r-md hover:bg-indigo-500 focus:z-10 focus:outline-none active:bg-indigo-700 focus:ring-blue"
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}`}
aria-label="Expand"
onClick={() => setIsOpen((state) => !state)}
>
@ -86,7 +128,9 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
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="bg-indigo-600 rounded-md ring-1 ring-black ring-opacity-5">
<div
className={`rounded-md ring-1 ring-black ring-opacity-5 ${styleClasses.dropdownClasses}`}
>
<div className="py-1">{children}</div>
</div>
</div>

@ -0,0 +1,57 @@
import React, { useRef, useState } from 'react';
import useClickOutside from '../../../hooks/useClickOutside';
import Button from '../Button';
interface ConfirmButtonProps {
onClick: () => void;
confirmText: React.ReactNode;
className?: string;
}
const ConfirmButton: React.FC<ConfirmButtonProps> = ({
onClick,
children,
confirmText,
className,
}) => {
const ref = useRef(null);
useClickOutside(ref, () => setIsClicked(false));
const [isClicked, setIsClicked] = useState(false);
return (
<Button
buttonType="danger"
className={`relative overflow-hidden ${className}`}
onClick={(e) => {
e.preventDefault();
if (!isClicked) {
setIsClicked(true);
} else {
onClick();
}
}}
>
&nbsp;
<div
ref={ref}
className={`absolute flex items-center justify-center inset-0 w-full h-full duration-300 transition transform-gpu ${
isClicked
? '-translate-y-full opacity-0'
: 'translate-y-0 opacity-100'
}`}
>
{children}
</div>
<div
ref={ref}
className={`absolute flex items-center justify-center inset-0 w-full h-full duration-300 transition transform-gpu ${
isClicked ? 'translate-y-0 opacity-100' : 'translate-y-full opacity-0'
}`}
>
{confirmText}
</div>
</Button>
);
};
export default ConfirmButton;

@ -7,6 +7,11 @@ import {
import TitleCard from '../../TitleCard';
import useVerticalScroll from '../../../hooks/useVerticalScroll';
import PersonCard from '../../PersonCard';
import { defineMessages, useIntl } from 'react-intl';
const messages = defineMessages({
noresults: 'No results.',
});
interface ListViewProps {
items?: (TvResult | MovieResult | PersonResult)[];
@ -23,12 +28,13 @@ const ListView: React.FC<ListViewProps> = ({
onScrollBottom,
isReachingEnd,
}) => {
const intl = useIntl();
useVerticalScroll(onScrollBottom, !isLoading && !isEmpty && !isReachingEnd);
return (
<>
{isEmpty && (
<div className="w-full mt-64 text-2xl text-center text-gray-400">
No Results
{intl.formatMessage(messages.noresults)}
</div>
)}
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
@ -47,6 +53,9 @@ const ListView: React.FC<ListViewProps> = ({
userScore={title.voteAverage}
year={title.releaseDate}
mediaType={title.mediaType}
inProgress={
(title.mediaInfo?.downloadStatus ?? []).length > 0
}
canExpand
/>
);
@ -62,6 +71,9 @@ const ListView: React.FC<ListViewProps> = ({
userScore={title.voteAverage}
year={title.firstAirDate}
mediaType={title.mediaType}
inProgress={
(title.mediaInfo?.downloadStatus ?? []).length > 0
}
canExpand
/>
);
@ -81,7 +93,7 @@ const ListView: React.FC<ListViewProps> = ({
return (
<li
key={title.id}
className="col-span-1 flex flex-col text-center items-center"
className="flex flex-col items-center col-span-1 text-center"
>
{titleCard}
</li>
@ -92,7 +104,7 @@ const ListView: React.FC<ListViewProps> = ({
[...Array(20)].map((_item, i) => (
<li
key={`placeholder-${i}`}
className="col-span-1 flex flex-col text-center items-center"
className="flex flex-col items-center col-span-1 text-center"
>
<TitleCard.Placeholder canExpand />
</li>

@ -5,6 +5,8 @@ import ListView from '../Common/ListView';
import { LanguageContext } from '../../context/LanguageContext';
import { defineMessages, FormattedMessage } from 'react-intl';
import Header from '../Common/Header';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
const messages = defineMessages({
discovermovies: 'Popular Movies',
@ -18,6 +20,7 @@ interface SearchResult {
}
const DiscoverMovies: React.FC = () => {
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(pageIndex: number, previousPageData: SearchResult | null) => {
@ -45,11 +48,20 @@ const DiscoverMovies: React.FC = () => {
return <div>{error}</div>;
}
const titles = data?.reduce(
let titles = (data ?? []).reduce(
(a, v) => [...a, ...v.results],
[] as MovieResult[]
);
if (settings.currentSettings.hideAvailable) {
titles = titles.filter(
(i) =>
(i.mediaType === 'movie' || i.mediaType === 'tv') &&
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
);
}
const isEmpty = !isLoadingInitialData && titles?.length === 0;
const isReachingEnd =
isEmpty || (data && data[data.length - 1]?.results.length < 20);

@ -5,6 +5,8 @@ import ListView from '../Common/ListView';
import { defineMessages, FormattedMessage } from 'react-intl';
import { LanguageContext } from '../../context/LanguageContext';
import Header from '../Common/Header';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
const messages = defineMessages({
discovertv: 'Popular Series',
@ -18,6 +20,7 @@ interface SearchResult {
}
const DiscoverTv: React.FC = () => {
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(pageIndex: number, previousPageData: SearchResult | null) => {
@ -45,7 +48,18 @@ const DiscoverTv: React.FC = () => {
return <div>{error}</div>;
}
const titles = data?.reduce((a, v) => [...a, ...v.results], [] as TvResult[]);
let titles = (data ?? []).reduce(
(a, v) => [...a, ...v.results],
[] as TvResult[]
);
if (settings.currentSettings.hideAvailable) {
titles = titles.filter(
(i) =>
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
);
}
const isEmpty = !isLoadingInitialData && titles?.length === 0;
const isReachingEnd =

@ -9,6 +9,8 @@ import ListView from '../Common/ListView';
import { LanguageContext } from '../../context/LanguageContext';
import { defineMessages, FormattedMessage } from 'react-intl';
import Header from '../Common/Header';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
const messages = defineMessages({
trending: 'Trending',
@ -22,6 +24,7 @@ interface SearchResult {
}
const Trending: React.FC = () => {
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(pageIndex: number, previousPageData: SearchResult | null) => {
@ -51,11 +54,20 @@ const Trending: React.FC = () => {
return <div>{error}</div>;
}
const titles = data?.reduce(
let titles = (data ?? []).reduce(
(a, v) => [...a, ...v.results],
[] as (MovieResult | TvResult | PersonResult)[]
);
if (settings.currentSettings.hideAvailable) {
titles = titles.filter(
(i) =>
(i.mediaType === 'movie' || i.mediaType === 'tv') &&
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
);
}
const isEmpty = !isLoadingInitialData && titles?.length === 0;
const isReachingEnd =
isEmpty || (data && data[data.length - 1]?.results.length < 20);

@ -5,6 +5,8 @@ import ListView from '../Common/ListView';
import { LanguageContext } from '../../context/LanguageContext';
import { defineMessages, FormattedMessage } from 'react-intl';
import Header from '../Common/Header';
import useSettings from '../../hooks/useSettings';
import { MediaStatus } from '../../../server/constants/media';
const messages = defineMessages({
upcomingmovies: 'Upcoming Movies',
@ -18,6 +20,7 @@ interface SearchResult {
}
const UpcomingMovies: React.FC = () => {
const settings = useSettings();
const { locale } = useContext(LanguageContext);
const { data, error, size, setSize } = useSWRInfinite<SearchResult>(
(pageIndex: number, previousPageData: SearchResult | null) => {
@ -47,11 +50,19 @@ const UpcomingMovies: React.FC = () => {
return <div>{error}</div>;
}
const titles = data?.reduce(
let titles = (data ?? []).reduce(
(a, v) => [...a, ...v.results],
[] as MovieResult[]
);
if (settings.currentSettings.hideAvailable) {
titles = titles.filter(
(i) =>
i.mediaInfo?.status !== MediaStatus.AVAILABLE &&
i.mediaInfo?.status !== MediaStatus.PARTIALLY_AVAILABLE
);
}
const isEmpty = !isLoadingInitialData && titles?.length === 0;
const isReachingEnd =
isEmpty || (data && data[data.length - 1]?.results.length < 20);

@ -23,7 +23,7 @@ const Discover: React.FC = () => {
const intl = useIntl();
const { data: media, error: mediaError } = useSWR<MediaResultsResponse>(
'/api/v1/media?filter=available&take=20&sort=mediaAdded'
'/api/v1/media?filter=allavailable&take=20&sort=mediaAdded'
);
const {

@ -0,0 +1,60 @@
import React from 'react';
import { FormattedRelativeTime } from 'react-intl';
import { DownloadingItem } from '../../../server/lib/downloadtracker';
import Badge from '../Common/Badge';
interface DownloadBlockProps {
downloadItem: DownloadingItem;
}
const DownloadBlock: React.FC<DownloadBlockProps> = ({ downloadItem }) => {
return (
<div className="p-4">
<div className="w-56 mb-2 text-sm truncate sm:w-80 md:w-full">
{downloadItem.title}
</div>
<div className="relative h-6 min-w-0 mb-2 overflow-hidden bg-gray-700 rounded-full">
<div
className="h-8 transition-all duration-200 ease-in-out bg-indigo-600"
style={{
width: `${Math.round(
((downloadItem.size - downloadItem.sizeLeft) /
downloadItem.size) *
100
)}%`,
}}
/>
<div className="absolute inset-0 flex items-center justify-center w-full h-6 text-xs">
<span>
{Math.round(
((downloadItem.size - downloadItem.sizeLeft) /
downloadItem.size) *
100
)}
%
</span>
</div>
</div>
<div className="flex items-center justify-between text-xs">
<Badge className="capitalize">{downloadItem.status}</Badge>
<span>
ETA{' '}
{downloadItem.estimatedCompletionTime ? (
<FormattedRelativeTime
value={Math.floor(
(new Date(downloadItem.estimatedCompletionTime).getTime() -
Date.now()) /
1000
)}
updateIntervalInSeconds={1}
/>
) : (
'N/A'
)}
</span>
</div>
</div>
);
};
export default DownloadBlock;

@ -2,12 +2,14 @@ import React from 'react';
import TmdbLogo from '../../assets/services/tmdb.svg';
import ImdbLogo from '../../assets/services/imdb.svg';
import RTLogo from '../../assets/services/rt.svg';
import PlexLogo from '../../assets/services/plex.svg';
interface ExternalLinkBlockProps {
mediaType: 'movie' | 'tv';
imdbId?: string;
tmdbId?: number;
rtUrl?: string;
plexUrl?: string;
}
const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
@ -15,9 +17,20 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
tmdbId,
rtUrl,
mediaType,
plexUrl,
}) => {
return (
<div className="flex justify-end items-center">
{plexUrl && (
<a
href={plexUrl}
className="w-8 mx-2 opacity-50 hover:opacity-100 transition duration-300"
target="_blank"
rel="noreferrer"
>
<PlexLogo />
</a>
)}
{tmdbId && (
<a
href={`https://www.themoviedb.org/${mediaType}/${tmdbId}`}

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

Loading…
Cancel
Save