commit
ed297d9d6c
@ -0,0 +1,102 @@
|
||||
name: Publish Snap
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop]
|
||||
tags: [v*]
|
||||
pull_request: ~
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Lint & Test Build
|
||||
runs-on: ubuntu-20.04
|
||||
if: "!contains(github.event.head_commit.message, '[skip ci]')"
|
||||
container: node:12.18-alpine
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: install dependencies
|
||||
env:
|
||||
HUSKY_SKIP_INSTALL: 1
|
||||
run: yarn
|
||||
- name: lint
|
||||
run: yarn lint
|
||||
- name: build
|
||||
run: yarn build
|
||||
build-snap:
|
||||
name: Build Snap Package (${{ matrix.architecture }})
|
||||
needs: test
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
architecture:
|
||||
- amd64
|
||||
- arm64
|
||||
- armhf
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Prepare
|
||||
id: prepare
|
||||
run: |
|
||||
git fetch --prune --unshallow --tags
|
||||
if [[ $GITHUB_REF == refs/tags/* || $GITHUB_REF == refs/heads/master ]]; then
|
||||
echo ::set-output name=RELEASE::stable
|
||||
else
|
||||
echo ::set-output name=RELEASE::edge
|
||||
fi
|
||||
|
||||
- name: Set Up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
- name: Build Snap Package
|
||||
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||
id: build
|
||||
with:
|
||||
architecture: ${{ matrix.architecture }}
|
||||
|
||||
- name: Upload Snap Package
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: overseerr-snap-package-${{ matrix.architecture }}
|
||||
path: ${{ steps.build.outputs.snap }}
|
||||
|
||||
- name: Review Snap Package
|
||||
uses: diddlesnaps/snapcraft-review-tools-action@v1
|
||||
with:
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
|
||||
- name: Publish Snap Package
|
||||
uses: snapcore/action-publish@v1
|
||||
with:
|
||||
store_login: ${{ secrets.SNAP_LOGIN }}
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
release: ${{ steps.prepare.outputs.RELEASE }}
|
||||
|
||||
discord:
|
||||
name: Send Discord Notification
|
||||
needs: build-snap
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- name: Get Build Job Status
|
||||
uses: technote-space/workflow-conclusion-action@v1
|
||||
|
||||
- name: Combine Job Status
|
||||
id: status
|
||||
run: |
|
||||
failures=(neutral, skipped, timed_out, action_required)
|
||||
if [[ ${array[@]} =~ $WORKFLOW_CONCLUSION ]]; then
|
||||
echo ::set-output name=status::failure
|
||||
else
|
||||
echo ::set-output name=status::$WORKFLOW_CONCLUSION
|
||||
fi
|
||||
|
||||
- name: Post Status to Discord
|
||||
uses: sarisia/actions-status-discord@v1
|
||||
with:
|
||||
webhook: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
status: ${{ steps.status.outputs.status }}
|
||||
title: ${{ github.workflow }}
|
||||
nofail: true
|
@ -1,17 +1,21 @@
|
||||
# Table of contents
|
||||
|
||||
* [Introduction](README.md)
|
||||
- [Introduction](README.md)
|
||||
|
||||
## Getting Started
|
||||
|
||||
* [Installation](getting-started/installation.md)
|
||||
- [Installation](getting-started/installation.md)
|
||||
|
||||
## Using Overseerr
|
||||
|
||||
- [Notifications](using-overseerr/notifications/README.md)
|
||||
- [Custom Webhooks](using-overseerr/notifications/webhooks.md)
|
||||
|
||||
## Support
|
||||
|
||||
* [Frequently Asked Questions](support/faq.md)
|
||||
* [Asking for Support](support/asking-for-support.md)
|
||||
- [Frequently Asked Questions](support/faq.md)
|
||||
- [Asking for Support](support/asking-for-support.md)
|
||||
|
||||
## Extending Overseerr
|
||||
|
||||
* [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md)
|
||||
|
||||
- [Reverse Proxy Examples](extending-overseerr/reverse-proxy-examples.md)
|
||||
|
@ -0,0 +1,28 @@
|
||||
# Notifications
|
||||
|
||||
Overseerr already supports a good number of notification agents, such as **Discord**, **Slack** and **Pushover**. New agents are always considered for development, if there is enough demand for it.
|
||||
|
||||
## Currently Supported Notification Agents
|
||||
|
||||
- Email
|
||||
- Discord
|
||||
- Slack
|
||||
- Telegram
|
||||
- Pushover
|
||||
- [Webhooks](./webhooks.md)
|
||||
|
||||
## 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.
|
||||
|
||||
You must configure which type of notifications you want to send _per agent_. If no types are selected, you will not receive any notifications!
|
||||
|
||||
Some agents may have specific configuration gotchas that will be covered in each notification agents documentation page.
|
||||
|
||||
{% hint style="danger" %}
|
||||
Currently, you will **not receive notifications** for any auto-approved requests. However, you will still receive a notification when the media becomes available.
|
||||
{% endhint %}
|
||||
|
||||
## Requesting new agents
|
||||
|
||||
If we do not currently support a notification agent you would like, feel free to request it on our [GitHub Issues](https://github.com/sct/overseerr/issues). Make sure to search first to see if someone else already requested it!
|
@ -0,0 +1,56 @@
|
||||
# Webhooks
|
||||
|
||||
Webhooks let you post a custom JSON payload to any endpoint you like. You can also set an authorization header for security purposes.
|
||||
|
||||
## Configuration
|
||||
|
||||
The following configuration options are available:
|
||||
|
||||
### Webhook URL (Required)
|
||||
|
||||
The URL you would like to post notifications to. Your JSON will be sent as the body of the request.
|
||||
|
||||
### Authorization Header
|
||||
|
||||
Custom authorization header. Anything entered for this will be sent as an `Authorization` header.
|
||||
|
||||
### Custom JSON Payload (Required)
|
||||
|
||||
Design your JSON payload as you see fit. JSON is validated before you can save or test. Overseerr provides several [template variables](./webhooks.md#template-variables) for use in the payload which will be replaced with actual values when the notifications are sent.
|
||||
|
||||
You can always reset back to the default custom payload setting by clicking the `Reset to Default JSON Payload` button under the editor.
|
||||
|
||||
## Template Variables
|
||||
|
||||
### Main
|
||||
|
||||
- `{{notification_type}}` The type of notification. (Ex. `MEDIA_PENDING` or `MEDIA_APPROVED`)
|
||||
- `{{subject}}` The notification subject message. (For request notifications, this is the media title)
|
||||
- `{{message}}` Notification message body. (For request notifications, this is the media's overview/synopsis)
|
||||
- `{{image}}` Associated image with the request. (For request notifications, this is the media's poster)
|
||||
|
||||
### Notify User
|
||||
|
||||
These variables are usually the target user of the notification.
|
||||
|
||||
- `{{notifyuser_username}}` Target user's username.
|
||||
- `{{notifyuser_email}}` Target user's email.
|
||||
- `{{notifyuser_avatar}}` Target user's avatar.
|
||||
|
||||
### Media
|
||||
|
||||
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_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`)
|
||||
|
||||
### Special Key Variables
|
||||
|
||||
These variables must be used as a key in the JSON Payload. (Ex, `"{{extra}}": []`).
|
||||
|
||||
- `{{extra}}` This will override the value of the property to be the pre-formatted "extra" array that can come along with certain notifications. Using this variable is _not required_.
|
||||
- `{{media}}` This will override the value of the property to `null` if there is no media object passed along with the notification.
|
After Width: | Height: | Size: 1.6 MiB |
Before Width: | Height: | Size: 3.4 MiB |
@ -0,0 +1,223 @@
|
||||
import axios from 'axios';
|
||||
import xml2js from 'xml2js';
|
||||
import fs, { promises as fsp } from 'fs';
|
||||
import path from 'path';
|
||||
import logger from '../logger';
|
||||
|
||||
const UPDATE_INTERVAL_MSEC = 24 * 3600 * 1000; // how often to download new mapping in milliseconds
|
||||
// originally at https://raw.githubusercontent.com/ScudLee/anime-lists/master/anime-list.xml
|
||||
const MAPPING_URL =
|
||||
'https://raw.githubusercontent.com/Anime-Lists/anime-lists/master/anime-list.xml';
|
||||
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
|
||||
// https://github.com/Anime-Lists/anime-lists/
|
||||
|
||||
interface AnimeMapping {
|
||||
$: {
|
||||
anidbseason: string;
|
||||
tvdbseason: string;
|
||||
};
|
||||
_: string;
|
||||
}
|
||||
|
||||
interface Anime {
|
||||
$: {
|
||||
anidbid: number;
|
||||
tvdbid?: string;
|
||||
defaulttvdbseason?: string;
|
||||
tmdbid?: number;
|
||||
imdbid?: string;
|
||||
};
|
||||
'mapping-list'?: {
|
||||
mapping: AnimeMapping[];
|
||||
}[];
|
||||
}
|
||||
|
||||
interface AnimeList {
|
||||
'anime-list': {
|
||||
anime: Anime[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnidbItem {
|
||||
tvdbId?: number;
|
||||
tmdbId?: number;
|
||||
imdbId?: string;
|
||||
}
|
||||
|
||||
class AnimeListMapping {
|
||||
private syncing = false;
|
||||
|
||||
private mapping: { [anidbId: number]: AnidbItem } = {};
|
||||
|
||||
// mapping file modification date when it was loaded
|
||||
private mappingModified: Date | null = null;
|
||||
|
||||
// each episode in season 0 from TVDB can map to movie
|
||||
private specials: { [tvdbId: number]: { [episode: number]: AnidbItem } } = {};
|
||||
|
||||
public isLoaded = () => Object.keys(this.mapping).length !== 0;
|
||||
|
||||
private loadFromFile = async () => {
|
||||
logger.info('Loading mapping file', { label: 'Anime-List Sync' });
|
||||
try {
|
||||
const mappingStat = await fsp.stat(LOCAL_PATH);
|
||||
const file = await fsp.readFile(LOCAL_PATH);
|
||||
const xml = (await xml2js.parseStringPromise(file)) as AnimeList;
|
||||
|
||||
this.mapping = {};
|
||||
this.specials = {};
|
||||
for (const anime of xml['anime-list'].anime) {
|
||||
// tvdbId can be nonnumber, like 'movie' string
|
||||
let tvdbId: number | undefined;
|
||||
if (anime.$.tvdbid && !isNaN(Number(anime.$.tvdbid))) {
|
||||
tvdbId = Number(anime.$.tvdbid);
|
||||
} else {
|
||||
tvdbId = undefined;
|
||||
}
|
||||
|
||||
let imdbIds: (string | undefined)[];
|
||||
if (anime.$.imdbid) {
|
||||
// if there are multiple imdb entries, then they map to different movies
|
||||
imdbIds = anime.$.imdbid.split(',');
|
||||
} else {
|
||||
// in case there is no imdbid, that's ok as there will be tmdbid
|
||||
imdbIds = [undefined];
|
||||
}
|
||||
|
||||
const tmdbId = anime.$.tmdbid ? Number(anime.$.tmdbid) : undefined;
|
||||
const anidbId = Number(anime.$.anidbid);
|
||||
this.mapping[anidbId] = {
|
||||
// for season 0 ignore tvdbid, because this must be movie/OVA
|
||||
tvdbId: anime.$.defaulttvdbseason === '0' ? undefined : tvdbId,
|
||||
tmdbId: tmdbId,
|
||||
imdbId: imdbIds[0], // this is used for one AniDB -> one imdb movie mapping
|
||||
};
|
||||
|
||||
if (tvdbId) {
|
||||
const mappingList = anime['mapping-list'];
|
||||
if (mappingList && mappingList.length != 0) {
|
||||
let imdbIndex = 0;
|
||||
for (const mapping of mappingList[0].mapping) {
|
||||
const text = mapping._;
|
||||
if (text && mapping.$.tvdbseason === '0') {
|
||||
let matches;
|
||||
while ((matches = mappingRegexp.exec(text)) !== null) {
|
||||
const episode = Number(matches[1]);
|
||||
if (!this.specials[tvdbId]) {
|
||||
this.specials[tvdbId] = {};
|
||||
}
|
||||
// map next available imdbid to episode in s0
|
||||
const imdbId =
|
||||
imdbIndex > imdbIds.length ? undefined : imdbIds[imdbIndex];
|
||||
if (tmdbId || imdbId) {
|
||||
this.specials[tvdbId][episode] = {
|
||||
tmdbId: tmdbId,
|
||||
imdbId: imdbId,
|
||||
};
|
||||
imdbIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// some movies do not have mapping-list, so map episode 1,2,3,..to movies
|
||||
// movies must have imdbid or tmdbid
|
||||
const hasImdb = imdbIds.length > 1 || imdbIds[0] !== undefined;
|
||||
if ((hasImdb || tmdbId) && anime.$.defaulttvdbseason === '0') {
|
||||
if (!this.specials[tvdbId]) {
|
||||
this.specials[tvdbId] = {};
|
||||
}
|
||||
// map each imdbid to episode in s0, episode index starts with 1
|
||||
for (let idx = 0; idx < imdbIds.length; idx++) {
|
||||
this.specials[tvdbId][idx + 1] = {
|
||||
tmdbId: tmdbId,
|
||||
imdbId: imdbIds[idx],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
this.mappingModified = mappingStat.mtime;
|
||||
logger.info(
|
||||
`Loaded ${
|
||||
Object.keys(this.mapping).length
|
||||
} AniDB items from mapping file`,
|
||||
{ label: 'Anime-List Sync' }
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to load Anime-List mappings: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
private downloadFile = async () => {
|
||||
logger.info('Downloading latest mapping file', {
|
||||
label: 'Anime-List Sync',
|
||||
});
|
||||
try {
|
||||
const response = await axios.get(MAPPING_URL, {
|
||||
responseType: 'stream',
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
const writer = fs.createWriteStream(LOCAL_PATH);
|
||||
writer.on('finish', resolve);
|
||||
response.data.pipe(writer);
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to download Anime-List mapping: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
public sync = async () => {
|
||||
// make sure only one sync runs at a time
|
||||
if (this.syncing) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncing = true;
|
||||
try {
|
||||
// check if local file is not "expired" yet
|
||||
if (fs.existsSync(LOCAL_PATH)) {
|
||||
const now = new Date();
|
||||
const stat = await fsp.stat(LOCAL_PATH);
|
||||
if (now.getTime() - stat.mtime.getTime() < UPDATE_INTERVAL_MSEC) {
|
||||
if (!this.isLoaded()) {
|
||||
// no need to download, but make sure file is loaded
|
||||
await this.loadFromFile();
|
||||
} else if (
|
||||
this.mappingModified &&
|
||||
stat.mtime.getTime() > this.mappingModified.getTime()
|
||||
) {
|
||||
// if file has been modified externally since last load, reload it
|
||||
await this.loadFromFile();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.downloadFile();
|
||||
await this.loadFromFile();
|
||||
} finally {
|
||||
this.syncing = false;
|
||||
}
|
||||
};
|
||||
|
||||
public getFromAnidbId = (anidbId: number): AnidbItem | undefined => {
|
||||
return this.mapping[anidbId];
|
||||
};
|
||||
|
||||
public getSpecialEpisode = (
|
||||
tvdbId: number,
|
||||
episode: number
|
||||
): AnidbItem | undefined => {
|
||||
const episodes = this.specials[tvdbId];
|
||||
return episodes ? episodes[episode] : undefined;
|
||||
};
|
||||
}
|
||||
|
||||
const animeList = new AnimeListMapping();
|
||||
|
||||
export default animeList;
|
@ -0,0 +1,4 @@
|
||||
export enum UserType {
|
||||
PLEX = 1,
|
||||
LOCAL = 2,
|
||||
}
|
@ -0,0 +1,18 @@
|
||||
import { RadarrProfile, RadarrRootFolder } from '../../api/radarr';
|
||||
|
||||
export interface ServiceCommonServer {
|
||||
id: number;
|
||||
name: string;
|
||||
is4k: boolean;
|
||||
isDefault: boolean;
|
||||
activeProfileId: number;
|
||||
activeDirectory: string;
|
||||
activeAnimeProfileId?: number;
|
||||
activeAnimeDirectory?: string;
|
||||
}
|
||||
|
||||
export interface ServiceCommonServerWithDetails {
|
||||
server: ServiceCommonServer;
|
||||
profiles: RadarrProfile[];
|
||||
rootFolders: Partial<RadarrRootFolder>[];
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import Email from 'email-templates';
|
||||
import { getSettings } from '../settings';
|
||||
class PreparedEmail extends Email {
|
||||
public constructor() {
|
||||
const settings = getSettings().notifications.agents.email;
|
||||
|
||||
const transport = nodemailer.createTransport({
|
||||
host: settings.options.smtpHost,
|
||||
port: settings.options.smtpPort,
|
||||
secure: settings.options.secure,
|
||||
tls: settings.options.allowSelfSigned
|
||||
? {
|
||||
rejectUnauthorized: false,
|
||||
}
|
||||
: undefined,
|
||||
auth:
|
||||
settings.options.authUser && settings.options.authPass
|
||||
? {
|
||||
user: settings.options.authUser,
|
||||
pass: settings.options.authPass,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
super({
|
||||
message: {
|
||||
from: {
|
||||
name: settings.options.senderName,
|
||||
address: settings.options.emailFrom,
|
||||
},
|
||||
},
|
||||
send: true,
|
||||
transport: transport,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default PreparedEmail;
|
@ -0,0 +1,139 @@
|
||||
import axios from 'axios';
|
||||
import { get } from 'lodash';
|
||||
import { hasNotificationType, Notification } from '..';
|
||||
import { MediaStatus } from '../../../constants/media';
|
||||
import logger from '../../../logger';
|
||||
import { getSettings, NotificationAgentWebhook } from '../../settings';
|
||||
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||
|
||||
type KeyMapFunction = (
|
||||
payload: NotificationPayload,
|
||||
type: Notification
|
||||
) => string;
|
||||
|
||||
const KeyMap: Record<string, string | KeyMapFunction> = {
|
||||
notification_type: (_payload, type) => Notification[type],
|
||||
subject: 'subject',
|
||||
message: 'message',
|
||||
image: 'image',
|
||||
notifyuser_username: 'notifyUser.username',
|
||||
notifyuser_email: 'notifyUser.email',
|
||||
notifyuser_avatar: 'notifyUser.avatar',
|
||||
media_tmdbid: 'media.tmdbId',
|
||||
media_imdbid: 'media.imdbId',
|
||||
media_tvdbid: 'media.tvdbId',
|
||||
media_type: 'media.mediaType',
|
||||
media_status: (payload) =>
|
||||
payload.media?.status ? MediaStatus[payload.media?.status] : '',
|
||||
media_status4k: (payload) =>
|
||||
payload.media?.status ? MediaStatus[payload.media?.status4k] : '',
|
||||
};
|
||||
|
||||
class WebhookAgent
|
||||
extends BaseAgent<NotificationAgentWebhook>
|
||||
implements NotificationAgent {
|
||||
protected getSettings(): NotificationAgentWebhook {
|
||||
if (this.settings) {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
|
||||
return settings.notifications.agents.webhook;
|
||||
}
|
||||
|
||||
private parseKeys(
|
||||
finalPayload: Record<string, unknown>,
|
||||
payload: NotificationPayload,
|
||||
type: Notification
|
||||
): Record<string, unknown> {
|
||||
Object.keys(finalPayload).forEach((key) => {
|
||||
if (key === '{{extra}}') {
|
||||
finalPayload.extra = payload.extra ?? [];
|
||||
delete finalPayload[key];
|
||||
key = 'extra';
|
||||
} else if (key === '{{media}}') {
|
||||
if (payload.media) {
|
||||
finalPayload.media = finalPayload[key];
|
||||
} else {
|
||||
finalPayload.media = null;
|
||||
}
|
||||
delete finalPayload[key];
|
||||
key = 'media';
|
||||
}
|
||||
|
||||
if (typeof finalPayload[key] === 'string') {
|
||||
Object.keys(KeyMap).forEach((keymapKey) => {
|
||||
const keymapValue = KeyMap[keymapKey as keyof typeof KeyMap];
|
||||
finalPayload[key] = (finalPayload[key] as string).replace(
|
||||
`{{${keymapKey}}}`,
|
||||
typeof keymapValue === 'function'
|
||||
? keymapValue(payload, type)
|
||||
: get(payload, keymapValue) ?? ''
|
||||
);
|
||||
});
|
||||
} else if (finalPayload[key] && typeof finalPayload[key] === 'object') {
|
||||
finalPayload[key] = this.parseKeys(
|
||||
finalPayload[key] as Record<string, unknown>,
|
||||
payload,
|
||||
type
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return finalPayload;
|
||||
}
|
||||
|
||||
private buildPayload(type: Notification, payload: NotificationPayload) {
|
||||
const payloadString = Buffer.from(
|
||||
this.getSettings().options.jsonPayload,
|
||||
'base64'
|
||||
).toString('ascii');
|
||||
|
||||
const parsedJSON = JSON.parse(JSON.parse(payloadString));
|
||||
|
||||
return this.parseKeys(parsedJSON, payload, type);
|
||||
}
|
||||
|
||||
public shouldSend(type: Notification): boolean {
|
||||
if (
|
||||
this.getSettings().enabled &&
|
||||
this.getSettings().options.webhookUrl &&
|
||||
hasNotificationType(type, this.getSettings().types)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async send(
|
||||
type: Notification,
|
||||
payload: NotificationPayload
|
||||
): Promise<boolean> {
|
||||
logger.debug('Sending webhook notification', { label: 'Notifications' });
|
||||
try {
|
||||
const { webhookUrl, authHeader } = this.getSettings().options;
|
||||
|
||||
if (!webhookUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await axios.post(webhookUrl, this.buildPayload(type, payload), {
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('Error sending Webhook notification', {
|
||||
label: 'Notifications',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default WebhookAgent;
|
@ -0,0 +1,43 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class LocalUsers1610070934506 implements MigrationInterface {
|
||||
name = 'LocalUsers1610070934506';
|
||||
|
||||
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 NOT NULL, "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 "temporary_user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt" 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 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 "temporary_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 "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 NOT NULL, "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"`);
|
||||
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 NOT NULL, "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')), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "user"("id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt") SELECT "id", "email", "username", "plexId", "plexToken", "permissions", "avatar", "createdAt", "updatedAt" FROM "temporary_user"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_user"`);
|
||||
}
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class Add4kStatusFields1610370640747 implements MigrationInterface {
|
||||
name = 'Add4kStatusFields1610370640747';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_season" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, "status4k" integer NOT NULL DEFAULT (1), CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId" FROM "season"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "season"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_season" RENAME TO "season"`
|
||||
);
|
||||
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), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" 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") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "media_request"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "media_request"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "temporary_media_request" RENAME TO "media_request"`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "media_request" RENAME TO "temporary_media_request"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById" FROM "temporary_media_request"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_media_request"`);
|
||||
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), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange" 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") `
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "season" RENAME TO "temporary_season"`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "season" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "seasonNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "mediaId" integer, CONSTRAINT "FK_087099b39600be695591da9a49c" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "season"("id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId") SELECT "id", "seasonNumber", "status", "createdAt", "updatedAt", "mediaId" FROM "temporary_season"`
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "temporary_season"`);
|
||||
}
|
||||
}
|
@ -0,0 +1,52 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddMediaAddedFieldToMedia1610522845513
|
||||
implements MigrationInterface {
|
||||
name = 'AddMediaAddedFieldToMedia1610522845513';
|
||||
|
||||
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, CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "temporary_media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k" 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), CONSTRAINT "UQ_41a289eb1fa489c1bc6f38d9c3c" UNIQUE ("tvdbId"))`
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "media"("id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k") SELECT "id", "mediaType", "tmdbId", "tvdbId", "imdbId", "status", "createdAt", "updatedAt", "lastSeasonChange", "status4k" 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") `
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,148 @@
|
||||
import { Router } from 'express';
|
||||
import RadarrAPI from '../api/radarr';
|
||||
import SonarrAPI from '../api/sonarr';
|
||||
import {
|
||||
ServiceCommonServer,
|
||||
ServiceCommonServerWithDetails,
|
||||
} from '../interfaces/api/serviceInterfaces';
|
||||
import { getSettings } from '../lib/settings';
|
||||
|
||||
const serviceRoutes = Router();
|
||||
|
||||
serviceRoutes.get('/radarr', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const filteredRadarrServers: ServiceCommonServer[] = settings.radarr.map(
|
||||
(radarr) => ({
|
||||
id: radarr.id,
|
||||
name: radarr.name,
|
||||
is4k: radarr.is4k,
|
||||
isDefault: radarr.isDefault,
|
||||
activeDirectory: radarr.activeDirectory,
|
||||
activeProfileId: radarr.activeProfileId,
|
||||
})
|
||||
);
|
||||
|
||||
return res.status(200).json(filteredRadarrServers);
|
||||
});
|
||||
|
||||
serviceRoutes.get<{ radarrId: string }>(
|
||||
'/radarr/:radarrId',
|
||||
async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const radarrSettings = settings.radarr.find(
|
||||
(radarr) => radarr.id === Number(req.params.radarrId)
|
||||
);
|
||||
|
||||
if (!radarrSettings) {
|
||||
return next({
|
||||
status: 404,
|
||||
message: 'Radarr server with provided ID does not exist.',
|
||||
});
|
||||
}
|
||||
|
||||
const radarr = new RadarrAPI({
|
||||
apiKey: radarrSettings.apiKey,
|
||||
url: `${radarrSettings.useSsl ? 'https' : 'http'}://${
|
||||
radarrSettings.hostname
|
||||
}:${radarrSettings.port}${radarrSettings.baseUrl ?? ''}/api`,
|
||||
});
|
||||
|
||||
const profiles = await radarr.getProfiles();
|
||||
const rootFolders = await radarr.getRootFolders();
|
||||
|
||||
return res.status(200).json({
|
||||
server: {
|
||||
id: radarrSettings.id,
|
||||
name: radarrSettings.name,
|
||||
is4k: radarrSettings.is4k,
|
||||
isDefault: radarrSettings.isDefault,
|
||||
activeDirectory: radarrSettings.activeDirectory,
|
||||
activeProfileId: radarrSettings.activeProfileId,
|
||||
},
|
||||
profiles: profiles.map((profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
})),
|
||||
rootFolders: rootFolders.map((folder) => ({
|
||||
id: folder.id,
|
||||
freeSpace: folder.freeSpace,
|
||||
path: folder.path,
|
||||
totalSpace: folder.totalSpace,
|
||||
})),
|
||||
} as ServiceCommonServerWithDetails);
|
||||
}
|
||||
);
|
||||
|
||||
serviceRoutes.get('/sonarr', async (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const filteredSonarrServers: ServiceCommonServer[] = settings.sonarr.map(
|
||||
(sonarr) => ({
|
||||
id: sonarr.id,
|
||||
name: sonarr.name,
|
||||
is4k: sonarr.is4k,
|
||||
isDefault: sonarr.isDefault,
|
||||
activeDirectory: sonarr.activeDirectory,
|
||||
activeProfileId: sonarr.activeProfileId,
|
||||
activeAnimeProfileId: sonarr.activeAnimeProfileId,
|
||||
activeAnimeDirectory: sonarr.activeAnimeDirectory,
|
||||
})
|
||||
);
|
||||
|
||||
return res.status(200).json(filteredSonarrServers);
|
||||
});
|
||||
|
||||
serviceRoutes.get<{ sonarrId: string }>(
|
||||
'/sonarr/:sonarrId',
|
||||
async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const sonarrSettings = settings.sonarr.find(
|
||||
(radarr) => radarr.id === Number(req.params.sonarrId)
|
||||
);
|
||||
|
||||
if (!sonarrSettings) {
|
||||
return next({
|
||||
status: 404,
|
||||
message: 'Radarr server with provided ID does not exist.',
|
||||
});
|
||||
}
|
||||
|
||||
const sonarr = new SonarrAPI({
|
||||
apiKey: sonarrSettings.apiKey,
|
||||
url: `${sonarrSettings.useSsl ? 'https' : 'http'}://${
|
||||
sonarrSettings.hostname
|
||||
}:${sonarrSettings.port}${sonarrSettings.baseUrl ?? ''}/api`,
|
||||
});
|
||||
|
||||
const profiles = await sonarr.getProfiles();
|
||||
const rootFolders = await sonarr.getRootFolders();
|
||||
|
||||
return res.status(200).json({
|
||||
server: {
|
||||
id: sonarrSettings.id,
|
||||
name: sonarrSettings.name,
|
||||
is4k: sonarrSettings.is4k,
|
||||
isDefault: sonarrSettings.isDefault,
|
||||
activeDirectory: sonarrSettings.activeDirectory,
|
||||
activeProfileId: sonarrSettings.activeProfileId,
|
||||
activeAnimeProfileId: sonarrSettings.activeAnimeProfileId,
|
||||
activeAnimeDirectory: sonarrSettings.activeAnimeDirectory,
|
||||
},
|
||||
profiles: profiles.map((profile) => ({
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
})),
|
||||
rootFolders: rootFolders.map((folder) => ({
|
||||
id: folder.id,
|
||||
freeSpace: folder.freeSpace,
|
||||
path: folder.path,
|
||||
totalSpace: folder.totalSpace,
|
||||
})),
|
||||
} as ServiceCommonServerWithDetails);
|
||||
}
|
||||
);
|
||||
|
||||
export default serviceRoutes;
|
@ -0,0 +1,265 @@
|
||||
import { Router } from 'express';
|
||||
import { getSettings } from '../../lib/settings';
|
||||
import { Notification } from '../../lib/notifications';
|
||||
import DiscordAgent from '../../lib/notifications/agents/discord';
|
||||
import EmailAgent from '../../lib/notifications/agents/email';
|
||||
import SlackAgent from '../../lib/notifications/agents/slack';
|
||||
import TelegramAgent from '../../lib/notifications/agents/telegram';
|
||||
import PushoverAgent from '../../lib/notifications/agents/pushover';
|
||||
import WebhookAgent from '../../lib/notifications/agents/webhook';
|
||||
|
||||
const notificationRoutes = Router();
|
||||
|
||||
notificationRoutes.get('/discord', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.discord);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/discord', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.discord = req.body;
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.discord);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/discord/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const discordAgent = new DiscordAgent(req.body);
|
||||
discordAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
notificationRoutes.get('/slack', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.slack);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/slack', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.slack = req.body;
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.slack);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/slack/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const slackAgent = new SlackAgent(req.body);
|
||||
slackAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
notificationRoutes.get('/telegram', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.telegram);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/telegram', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.telegram = req.body;
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.telegram);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/telegram/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const telegramAgent = new TelegramAgent(req.body);
|
||||
telegramAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
notificationRoutes.get('/pushover', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.pushover);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/pushover', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.pushover = req.body;
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.pushover);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/pushover/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const pushoverAgent = new PushoverAgent(req.body);
|
||||
pushoverAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
notificationRoutes.get('/email', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.email);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/email', (req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
settings.notifications.agents.email = req.body;
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.email);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/email/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
const emailAgent = new EmailAgent(req.body);
|
||||
emailAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
});
|
||||
|
||||
notificationRoutes.get('/webhook', (_req, res) => {
|
||||
const settings = getSettings();
|
||||
|
||||
const webhookSettings = settings.notifications.agents.webhook;
|
||||
|
||||
const response: typeof webhookSettings = {
|
||||
enabled: webhookSettings.enabled,
|
||||
types: webhookSettings.types,
|
||||
options: {
|
||||
...webhookSettings.options,
|
||||
jsonPayload: JSON.parse(
|
||||
Buffer.from(webhookSettings.options.jsonPayload, 'base64').toString(
|
||||
'ascii'
|
||||
)
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
});
|
||||
|
||||
notificationRoutes.post('/webhook', (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
try {
|
||||
JSON.parse(req.body.options.jsonPayload);
|
||||
|
||||
settings.notifications.agents.webhook = {
|
||||
enabled: req.body.enabled,
|
||||
types: req.body.types,
|
||||
options: {
|
||||
jsonPayload: Buffer.from(req.body.options.jsonPayload).toString(
|
||||
'base64'
|
||||
),
|
||||
webhookUrl: req.body.options.webhookUrl,
|
||||
authHeader: req.body.options.authHeader,
|
||||
},
|
||||
};
|
||||
settings.save();
|
||||
|
||||
res.status(200).json(settings.notifications.agents.webhook);
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
notificationRoutes.post('/webhook/test', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'User information missing from request',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(req.body.options.jsonPayload);
|
||||
|
||||
const testBody = {
|
||||
enabled: req.body.enabled,
|
||||
types: req.body.types,
|
||||
options: {
|
||||
jsonPayload: Buffer.from(req.body.options.jsonPayload).toString(
|
||||
'base64'
|
||||
),
|
||||
webhookUrl: req.body.options.webhookUrl,
|
||||
authHeader: req.body.options.authHeader,
|
||||
},
|
||||
};
|
||||
|
||||
const webhookAgent = new WebhookAgent(testBody);
|
||||
webhookAgent.send(Notification.TEST_NOTIFICATION, {
|
||||
notifyUser: req.user,
|
||||
subject: 'Test Notification',
|
||||
message:
|
||||
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
|
||||
});
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
next({ status: 500, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default notificationRoutes;
|
@ -0,0 +1,98 @@
|
||||
doctype html
|
||||
head
|
||||
meta(charset='utf-8')
|
||||
meta(name='x-apple-disable-message-reformatting')
|
||||
meta(http-equiv='x-ua-compatible' content='ie=edge')
|
||||
meta(name='viewport' content='width=device-width, initial-scale=1')
|
||||
meta(name='format-detection' content='telephone=no, date=no, address=no, email=no')
|
||||
link(href='https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap' rel='stylesheet' media='screen')
|
||||
//if mso
|
||||
xml
|
||||
o:officedocumentsettings
|
||||
o:pixelsperinch 96
|
||||
style.
|
||||
td,
|
||||
th,
|
||||
div,
|
||||
p,
|
||||
a,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-family: 'Segoe UI', sans-serif;
|
||||
mso-line-height-rule: exactly;
|
||||
}
|
||||
style.
|
||||
@media (max-width: 600px) {
|
||||
.sm-w-full {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
div(role='article' aria-roledescription='email' aria-label='' lang='en')
|
||||
table(style="\
|
||||
background-color: #f2f4f6;\
|
||||
font-family: 'Nunito Sans', -apple-system, 'Segoe UI', sans-serif;\
|
||||
width: 100%;\
|
||||
" width='100%' bgcolor='#f2f4f6' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(align='center')
|
||||
table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(align='center' style='\
|
||||
font-size: 16px;\
|
||||
padding-top: 25px;\
|
||||
padding-bottom: 25px;\
|
||||
text-align: center;\
|
||||
')
|
||||
a(href=applicationUrl style='\
|
||||
text-shadow: 0 1px 0 #ffffff;\
|
||||
font-weight: 700;\
|
||||
font-size: 16px;\
|
||||
color: #a8aaaf;\
|
||||
text-decoration: none;\
|
||||
')
|
||||
| Overseerr
|
||||
tr
|
||||
td(style='width: 100%' width='100%')
|
||||
table.sm-w-full(align='center' style='\
|
||||
background-color: #ffffff;\
|
||||
margin-left: auto;\
|
||||
margin-right: auto;\
|
||||
width: 570px;\
|
||||
' width='570' bgcolor='#ffffff' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(style='padding: 45px')
|
||||
div(style='font-size: 16px; text-align: center; padding-bottom: 14px;')
|
||||
| Your new password is:
|
||||
div(style='font-size: 16px; text-align: center')
|
||||
| #{password}
|
||||
p(style='\
|
||||
font-size: 13px;\
|
||||
line-height: 24px;\
|
||||
margin-top: 6px;\
|
||||
margin-bottom: 20px;\
|
||||
color: #51545e;\
|
||||
')
|
||||
a(href=applicationUrl style='color: #3869d4') Open Overseerr
|
||||
tr
|
||||
td
|
||||
table.sm-w-full(align='center' style='\
|
||||
margin-left: auto;\
|
||||
margin-right: auto;\
|
||||
text-align: center;\
|
||||
width: 570px;\
|
||||
' width='570' cellpadding='0' cellspacing='0' role='presentation')
|
||||
tr
|
||||
td(align='center' style='font-size: 16px; padding: 45px')
|
||||
p(style='\
|
||||
font-size: 13px;\
|
||||
line-height: 24px;\
|
||||
margin-top: 6px;\
|
||||
margin-bottom: 20px;\
|
||||
text-align: center;\
|
||||
color: #a8aaaf;\
|
||||
')
|
||||
| Overseerr.
|
@ -0,0 +1 @@
|
||||
= `Password reset - Overseerr`
|
@ -0,0 +1,54 @@
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
// whenever you need to run async code on tv show or movie that does "get existing" / "check if need to create new" / "save"
|
||||
// then you need to put all of that code in "await asyncLock.dispatch" callback based on media id
|
||||
// this will guarantee that only one part of code will run at the same for this media id to avoid code
|
||||
// trying to create two or more entries for same movie/tvshow (which would result in sqlite unique constraint failrue)
|
||||
|
||||
class AsyncLock {
|
||||
private locked: { [key: string]: boolean } = {};
|
||||
private ee = new EventEmitter();
|
||||
|
||||
constructor() {
|
||||
this.ee.setMaxListeners(0);
|
||||
}
|
||||
|
||||
private acquire = async (key: string) => {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.locked[key]) {
|
||||
this.locked[key] = true;
|
||||
return resolve(undefined);
|
||||
}
|
||||
|
||||
const nextAcquire = () => {
|
||||
if (!this.locked[key]) {
|
||||
this.locked[key] = true;
|
||||
this.ee.removeListener(key, nextAcquire);
|
||||
return resolve(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
this.ee.on(key, nextAcquire);
|
||||
});
|
||||
};
|
||||
|
||||
private release = (key: string): void => {
|
||||
delete this.locked[key];
|
||||
setImmediate(() => this.ee.emit(key));
|
||||
};
|
||||
|
||||
public dispatch = async (
|
||||
key: string | number,
|
||||
callback: () => Promise<void>
|
||||
) => {
|
||||
const skey = String(key);
|
||||
await this.acquire(skey);
|
||||
try {
|
||||
await callback();
|
||||
} finally {
|
||||
this.release(skey);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default AsyncLock;
|
@ -0,0 +1,93 @@
|
||||
name: overseerr
|
||||
adopt-info: overseerr
|
||||
license: MIT
|
||||
summary: Request management and media discovery tool for the Plex ecosystem.
|
||||
description: >
|
||||
Overseerr is a free and open source software application for managing requests for your media library.
|
||||
It integrates with your existing services such as Sonarr, Radarr and Plex!
|
||||
base: core18
|
||||
confinement: strict
|
||||
|
||||
parts:
|
||||
overseerr:
|
||||
plugin: nodejs
|
||||
nodejs-version: "12.18.4"
|
||||
nodejs-package-manager: "yarn"
|
||||
nodejs-yarn-version: v1.22.5
|
||||
build-packages:
|
||||
- git
|
||||
- on arm64:
|
||||
- build-essential
|
||||
- automake
|
||||
- python-gi
|
||||
- python-gi-dev
|
||||
- on armhf:
|
||||
- libatomic1
|
||||
- build-essential
|
||||
- automake
|
||||
- python-gi
|
||||
- python-gi-dev
|
||||
source: .
|
||||
override-pull: |
|
||||
snapcraftctl pull
|
||||
# Get information to determine snap grade and version
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
COMMIT=$(git rev-parse HEAD)
|
||||
COMMIT_SHORT=$(git rev-parse --short HEAD)
|
||||
VERSION='v'$(cat package.json | grep 'version' | head -1 | sed 's/.*"\(.*\)"\,/\1/')
|
||||
if [ "$VERSION" = "v0.1.0" ]; then
|
||||
SNAP_VERSION=$COMMIT_SHORT
|
||||
GRADE=devel
|
||||
else
|
||||
SNAP_VERSION=$VERSION
|
||||
GRADE=stable
|
||||
fi
|
||||
# Write COMMIT_TAG as it is needed durring the build process
|
||||
echo $COMMIT > commit.txt
|
||||
# Print debug info for build version
|
||||
echo "{\"commitShort\": \"$COMMIT_SHORT\", \
|
||||
\"version\": \"$VERSION\", \
|
||||
\"snapVersion\": \"$SNAP_VERSION\", \
|
||||
\"snapGrade\": \"$GRADE\", \
|
||||
\"branch\": \"$BRANCH\", \
|
||||
\"commit\": \"$COMMIT\"}"
|
||||
echo "{\"commitTag\": \"$COMMIT\"}" > committag.json
|
||||
# Set snap version and grade
|
||||
snapcraftctl set-version "$SNAP_VERSION"
|
||||
snapcraftctl set-grade "$GRADE"
|
||||
build-environment:
|
||||
- PATH: "$SNAPCRAFT_PART_BUILD/node_modules/.bin:$SNAPCRAFT_PART_BUILD/../npm/bin:$PATH"
|
||||
override-build: |
|
||||
set -e
|
||||
# Set COMMIT_TAG before the build begins
|
||||
export COMMIT_TAG=$(cat $SNAPCRAFT_PART_BUILD/commit.txt)
|
||||
snapcraftctl build
|
||||
yarn build
|
||||
# Copy files needed for staging
|
||||
cp $SNAPCRAFT_PART_BUILD/committag.json $SNAPCRAFT_PART_INSTALL/
|
||||
cp -R $SNAPCRAFT_PART_BUILD/.next $SNAPCRAFT_PART_INSTALL/
|
||||
cp -R $SNAPCRAFT_PART_BUILD/dist $SNAPCRAFT_PART_INSTALL/
|
||||
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:
|
||||
[ .next, ./* ]
|
||||
prime:
|
||||
[ .next, ./* ]
|
||||
|
||||
apps:
|
||||
deamon:
|
||||
command: /bin/sh -c "cd $SNAP && node dist/index.js"
|
||||
daemon: simple
|
||||
restart-condition: on-failure
|
||||
restart-delay: 5s
|
||||
plugs:
|
||||
- home
|
||||
- network
|
||||
- network-bind
|
||||
environment:
|
||||
PATH: "$SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH"
|
||||
OVERSEERR_SNAP: "True"
|
||||
CONFIG_DIRECTORY: $SNAP_USER_COMMON
|
||||
LOG_LEVEL: "debug"
|
||||
NODE_ENV: "production"
|
After Width: | Height: | Size: 257 B |
Before Width: | Height: | Size: 2.7 KiB |
After Width: | Height: | Size: 291 B |
@ -0,0 +1,35 @@
|
||||
import React, { HTMLAttributes } from 'react';
|
||||
import AceEditor from 'react-ace';
|
||||
import 'ace-builds/src-noconflict/mode-json';
|
||||
import 'ace-builds/src-noconflict/theme-dracula';
|
||||
|
||||
interface JSONEditorProps extends HTMLAttributes<HTMLDivElement> {
|
||||
name: string;
|
||||
value: string;
|
||||
onUpdate: (value: string) => void;
|
||||
}
|
||||
|
||||
const JSONEditor: React.FC<JSONEditorProps> = ({
|
||||
name,
|
||||
value,
|
||||
onUpdate,
|
||||
onBlur,
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-full overflow-hidden rounded-md">
|
||||
<AceEditor
|
||||
mode="json"
|
||||
theme="dracula"
|
||||
onChange={onUpdate}
|
||||
name={name}
|
||||
editorProps={{ $blockScrolling: true }}
|
||||
value={value}
|
||||
onBlur={onBlur}
|
||||
height="300px"
|
||||
width="100%"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JSONEditor;
|
@ -0,0 +1,143 @@
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import Button from '../Common/Button';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import axios from 'axios';
|
||||
|
||||
const messages = defineMessages({
|
||||
email: 'Email Address',
|
||||
password: 'Password',
|
||||
validationemailrequired: 'Not a valid email address',
|
||||
validationpasswordrequired: 'Password required',
|
||||
loginerror: 'Something went wrong when trying to sign in',
|
||||
loggingin: 'Logging in...',
|
||||
login: 'Login',
|
||||
goback: 'Go back',
|
||||
});
|
||||
|
||||
interface LocalLoginProps {
|
||||
goBack: () => void;
|
||||
revalidate: () => void;
|
||||
}
|
||||
|
||||
const LocalLogin: React.FC<LocalLoginProps> = ({ goBack, revalidate }) => {
|
||||
const intl = useIntl();
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
|
||||
const LoginSchema = Yup.object().shape({
|
||||
email: Yup.string()
|
||||
.email()
|
||||
.required(intl.formatMessage(messages.validationemailrequired)),
|
||||
password: Yup.string().required(
|
||||
intl.formatMessage(messages.validationpasswordrequired)
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
email: '',
|
||||
password: '',
|
||||
}}
|
||||
validationSchema={LoginSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post('/api/v1/auth/local', {
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
});
|
||||
} catch (e) {
|
||||
setLoginError(intl.formatMessage(messages.loginerror));
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting, isValid }) => {
|
||||
return (
|
||||
<>
|
||||
<Form>
|
||||
<div className="sm:border-t sm:border-gray-800">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="block my-1 text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.email)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="email"
|
||||
name="email"
|
||||
type="text"
|
||||
placeholder="name@example.com"
|
||||
className="text-white flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && touched.email && (
|
||||
<div className="mt-2 text-red-500">{errors.email}</div>
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block my-1 text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.password)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder={intl.formatMessage(messages.password)}
|
||||
className="text-white flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.password && touched.password && (
|
||||
<div className="mt-2 text-red-500">{errors.password}</div>
|
||||
)}
|
||||
</div>
|
||||
{loginError && (
|
||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||
<div className="mt-2 text-red-500">{loginError}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-5 mt-8 border-t border-gray-700">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
type="reset"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
goBack();
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.goback)}
|
||||
</Button>
|
||||
</span>
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.loggingin)
|
||||
: intl.formatMessage(messages.login)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocalLogin;
|
@ -0,0 +1,104 @@
|
||||
import Link from 'next/link';
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
seemore: 'See More',
|
||||
});
|
||||
|
||||
interface ShowMoreCardProps {
|
||||
url: string;
|
||||
posters: (string | undefined)[];
|
||||
}
|
||||
|
||||
const ShowMoreCard: React.FC<ShowMoreCardProps> = ({ url, posters }) => {
|
||||
const intl = useIntl();
|
||||
const [isHovered, setHovered] = useState(false);
|
||||
return (
|
||||
<Link href={url}>
|
||||
<a
|
||||
className={'w-36 sm:w-36 md:w-44'}
|
||||
onMouseEnter={() => {
|
||||
setHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setHovered(true);
|
||||
}
|
||||
}}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
className={`relative w-36 sm:w-36 md:w-44
|
||||
rounded-lg text-white shadow-lg overflow-hidden transition ease-in-out duration-150 cursor-pointer transform-gpu ${
|
||||
isHovered ? 'bg-gray-500 scale-105' : 'bg-gray-600 scale-100'
|
||||
}`}
|
||||
>
|
||||
<div style={{ paddingBottom: '150%' }}>
|
||||
<div className="absolute inset-0 flex flex-col items-center w-full h-full p-2">
|
||||
<div className="relative z-10 flex flex-wrap items-center justify-center h-full opacity-30">
|
||||
{posters[0] && (
|
||||
<div className="w-1/2 p-1">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[0]}`}
|
||||
alt=""
|
||||
className="w-full rounded-md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{posters[1] && (
|
||||
<div className="w-1/2 p-1">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[1]}`}
|
||||
alt=""
|
||||
className="w-full rounded-md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{posters[2] && (
|
||||
<div className="w-1/2 p-1">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[2]}`}
|
||||
alt=""
|
||||
className="w-full rounded-md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{posters[3] && (
|
||||
<div className="w-1/2 p-1">
|
||||
<img
|
||||
src={`//image.tmdb.org/t/p/w300_and_h450_face${posters[3]}`}
|
||||
alt=""
|
||||
className="w-full rounded-md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center text-white">
|
||||
<svg
|
||||
className="w-14"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-8.707l-3-3a1 1 0 00-1.414 1.414L10.586 9H7a1 1 0 100 2h3.586l-1.293 1.293a1 1 0 101.414 1.414l3-3a1 1 0 000-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="mt-2 font-extrabold">
|
||||
{intl.formatMessage(messages.seemore)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShowMoreCard;
|
@ -0,0 +1,153 @@
|
||||
import Link from 'next/link';
|
||||
import React, { useContext } from 'react';
|
||||
import { useSWRInfinite } from 'swr';
|
||||
import type {
|
||||
MovieResult,
|
||||
PersonResult,
|
||||
TvResult,
|
||||
} from '../../../server/models/Search';
|
||||
import { LanguageContext } from '../../context/LanguageContext';
|
||||
import PersonCard from '../PersonCard';
|
||||
import Slider from '../Slider';
|
||||
import TitleCard from '../TitleCard';
|
||||
import ShowMoreCard from './ShowMoreCard';
|
||||
|
||||
interface MixedResult {
|
||||
page: number;
|
||||
totalResults: number;
|
||||
totalPages: number;
|
||||
results: (TvResult | MovieResult | PersonResult)[];
|
||||
}
|
||||
|
||||
interface MediaSliderProps {
|
||||
title: string;
|
||||
url: string;
|
||||
linkUrl?: string;
|
||||
sliderKey: string;
|
||||
hideWhenEmpty?: boolean;
|
||||
}
|
||||
|
||||
const MediaSlider: React.FC<MediaSliderProps> = ({
|
||||
title,
|
||||
url,
|
||||
linkUrl,
|
||||
sliderKey,
|
||||
hideWhenEmpty = false,
|
||||
}) => {
|
||||
const { locale } = useContext(LanguageContext);
|
||||
const { data, error } = useSWRInfinite<MixedResult>(
|
||||
(pageIndex: number, previousPageData: MixedResult | null) => {
|
||||
if (previousPageData && pageIndex + 1 > previousPageData.totalPages) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${url}?page=${pageIndex + 1}&language=${locale}`;
|
||||
},
|
||||
{
|
||||
initialSize: 2,
|
||||
}
|
||||
);
|
||||
|
||||
if (hideWhenEmpty && (data?.[0].results ?? []).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const titles = (data ?? []).reduce(
|
||||
(a, v) => [...a, ...v.results],
|
||||
[] as (MovieResult | TvResult | PersonResult)[]
|
||||
);
|
||||
|
||||
const finalTitles = titles.slice(0, 20).map((title) => {
|
||||
switch (title.mediaType) {
|
||||
case 'movie':
|
||||
return (
|
||||
<TitleCard
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
title={title.title}
|
||||
userScore={title.voteAverage}
|
||||
year={title.releaseDate}
|
||||
mediaType={title.mediaType}
|
||||
/>
|
||||
);
|
||||
case 'tv':
|
||||
return (
|
||||
<TitleCard
|
||||
id={title.id}
|
||||
image={title.posterPath}
|
||||
status={title.mediaInfo?.status}
|
||||
summary={title.overview}
|
||||
title={title.name}
|
||||
userScore={title.voteAverage}
|
||||
year={title.firstAirDate}
|
||||
mediaType={title.mediaType}
|
||||
/>
|
||||
);
|
||||
case 'person':
|
||||
return (
|
||||
<PersonCard
|
||||
personId={title.id}
|
||||
name={title.name}
|
||||
profilePath={title.profilePath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (linkUrl && titles.length > 20) {
|
||||
finalTitles.push(
|
||||
<ShowMoreCard
|
||||
url={linkUrl}
|
||||
posters={titles
|
||||
.slice(20, 24)
|
||||
.map((title) =>
|
||||
title.mediaType !== 'person' ? title.posterPath : undefined
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
{linkUrl ? (
|
||||
<Link href={linkUrl}>
|
||||
<a className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>{title}</span>
|
||||
<svg
|
||||
className="w-6 h-6 ml-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="inline-flex items-center text-xl leading-7 text-gray-300 sm:text-2xl sm:leading-9 sm:truncate">
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
sliderKey={sliderKey}
|
||||
isLoading={!data && !error}
|
||||
isEmpty={false}
|
||||
items={finalTitles}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaSlider;
|
@ -0,0 +1,563 @@
|
||||
import axios from 'axios';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import {
|
||||
MediaRequestStatus,
|
||||
MediaStatus,
|
||||
} from '../../../server/constants/media';
|
||||
import Media from '../../../server/entity/Media';
|
||||
import { MediaRequest } from '../../../server/entity/MediaRequest';
|
||||
import { SettingsContext } from '../../context/SettingsContext';
|
||||
import { Permission, useUser } from '../../hooks/useUser';
|
||||
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
|
||||
import RequestModal from '../RequestModal';
|
||||
|
||||
const messages = defineMessages({
|
||||
viewrequest: 'View Request',
|
||||
viewrequest4k: 'View 4K Request',
|
||||
request: 'Request',
|
||||
request4k: 'Request 4K',
|
||||
requestmore: 'Request More',
|
||||
requestmore4k: 'Request More 4K',
|
||||
approverequest: 'Approve Request',
|
||||
approverequest4k: 'Approve 4K Request',
|
||||
declinerequest: 'Decline Request',
|
||||
declinerequest4k: 'Decline 4K Request',
|
||||
approverequests:
|
||||
'Approve {requestCount} {requestCount, plural, one {Request} other {Requests}}',
|
||||
declinerequests:
|
||||
'Decline {requestCount} {requestCount, plural, one {Request} other {Requests}}',
|
||||
approve4krequests:
|
||||
'Approve {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}',
|
||||
decline4krequests:
|
||||
'Decline {requestCount} 4K {requestCount, plural, one {Request} other {Requests}}',
|
||||
});
|
||||
|
||||
interface ButtonOption {
|
||||
id: string;
|
||||
text: string;
|
||||
action: () => void;
|
||||
svg?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface RequestButtonProps {
|
||||
mediaType: 'movie' | 'tv';
|
||||
onUpdate: () => void;
|
||||
tmdbId: number;
|
||||
media?: Media;
|
||||
isShowComplete?: boolean;
|
||||
is4kShowComplete?: boolean;
|
||||
}
|
||||
|
||||
const RequestButton: React.FC<RequestButtonProps> = ({
|
||||
tmdbId,
|
||||
onUpdate,
|
||||
media,
|
||||
mediaType,
|
||||
isShowComplete = false,
|
||||
is4kShowComplete = false,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const settings = useContext(SettingsContext);
|
||||
const { hasPermission } = useUser();
|
||||
const [showRequestModal, setShowRequestModal] = useState(false);
|
||||
const [showRequest4kModal, setShowRequest4kModal] = useState(false);
|
||||
|
||||
const activeRequest = media?.requests.find(
|
||||
(request) => request.status === MediaRequestStatus.PENDING && !request.is4k
|
||||
);
|
||||
const active4kRequest = media?.requests.find(
|
||||
(request) => request.status === MediaRequestStatus.PENDING && request.is4k
|
||||
);
|
||||
|
||||
// All pending
|
||||
const activeRequests = media?.requests.filter(
|
||||
(request) => request.status === MediaRequestStatus.PENDING && !request.is4k
|
||||
);
|
||||
|
||||
const active4kRequests = media?.requests.filter(
|
||||
(request) => request.status === MediaRequestStatus.PENDING && request.is4k
|
||||
);
|
||||
|
||||
const modifyRequest = async (
|
||||
request: MediaRequest,
|
||||
type: 'approve' | 'decline'
|
||||
) => {
|
||||
const response = await axios.get(`/api/v1/request/${request.id}/${type}`);
|
||||
|
||||
if (response) {
|
||||
onUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const modifyRequests = async (
|
||||
requests: MediaRequest[],
|
||||
type: 'approve' | 'decline'
|
||||
): Promise<void> => {
|
||||
if (!requests) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
requests.map(async (request) => {
|
||||
return axios.get(`/api/v1/request/${request.id}/${type}`);
|
||||
})
|
||||
);
|
||||
|
||||
onUpdate();
|
||||
};
|
||||
|
||||
const buttons: ButtonOption[] = [];
|
||||
if (
|
||||
(!media || media.status === MediaStatus.UNKNOWN) &&
|
||||
hasPermission(Permission.REQUEST)
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'request',
|
||||
text: intl.formatMessage(messages.request),
|
||||
action: () => {
|
||||
setShowRequestModal(true);
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
hasPermission(Permission.REQUEST) &&
|
||||
mediaType === 'tv' &&
|
||||
media &&
|
||||
media.status !== MediaStatus.AVAILABLE &&
|
||||
media.status !== MediaStatus.UNKNOWN &&
|
||||
!isShowComplete
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'request-more',
|
||||
text: intl.formatMessage(messages.requestmore),
|
||||
action: () => {
|
||||
setShowRequestModal(true);
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(!media || media.status4k === MediaStatus.UNKNOWN) &&
|
||||
(hasPermission(Permission.REQUEST_4K) ||
|
||||
(mediaType === 'movie' && hasPermission(Permission.REQUEST_4K_MOVIE)) ||
|
||||
(mediaType === 'tv' && hasPermission(Permission.REQUEST_4K_TV))) &&
|
||||
((settings.currentSettings.movie4kEnabled && mediaType === 'movie') ||
|
||||
(settings.currentSettings.series4kEnabled && mediaType === 'tv'))
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'request4k',
|
||||
text: intl.formatMessage(messages.request4k),
|
||||
action: () => {
|
||||
setShowRequest4kModal(true);
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
mediaType === 'tv' &&
|
||||
(hasPermission(Permission.REQUEST_4K) ||
|
||||
(mediaType === 'tv' && hasPermission(Permission.REQUEST_4K_TV))) &&
|
||||
media &&
|
||||
media.status4k !== MediaStatus.AVAILABLE &&
|
||||
media.status4k !== MediaStatus.UNKNOWN &&
|
||||
!is4kShowComplete &&
|
||||
settings.currentSettings.series4kEnabled
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'request-more-4k',
|
||||
text: intl.formatMessage(messages.requestmore4k),
|
||||
action: () => {
|
||||
setShowRequest4kModal(true);
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
activeRequest &&
|
||||
mediaType === 'movie' &&
|
||||
hasPermission(Permission.REQUEST)
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'active-request',
|
||||
text: intl.formatMessage(messages.viewrequest),
|
||||
action: () => setShowRequestModal(true),
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
active4kRequest &&
|
||||
mediaType === 'movie' &&
|
||||
(hasPermission(Permission.REQUEST_4K) ||
|
||||
hasPermission(Permission.REQUEST_4K_MOVIE))
|
||||
) {
|
||||
buttons.push({
|
||||
id: 'active-4k-request',
|
||||
text: intl.formatMessage(messages.viewrequest4k),
|
||||
action: () => setShowRequest4kModal(true),
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
activeRequest &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
mediaType === 'movie'
|
||||
) {
|
||||
buttons.push(
|
||||
{
|
||||
id: 'approve-request',
|
||||
text: intl.formatMessage(messages.approverequest),
|
||||
action: () => {
|
||||
modifyRequest(activeRequest, 'approve');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'decline-request',
|
||||
text: intl.formatMessage(messages.declinerequest),
|
||||
action: () => {
|
||||
modifyRequest(activeRequest, 'decline');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
activeRequests &&
|
||||
activeRequests.length > 0 &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
mediaType === 'tv'
|
||||
) {
|
||||
buttons.push(
|
||||
{
|
||||
id: 'approve-request-batch',
|
||||
text: intl.formatMessage(messages.approverequests, {
|
||||
requestCount: activeRequests.length,
|
||||
}),
|
||||
action: () => {
|
||||
modifyRequests(activeRequests, 'approve');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'decline-request-batch',
|
||||
text: intl.formatMessage(messages.declinerequests, {
|
||||
requestCount: activeRequests.length,
|
||||
}),
|
||||
action: () => {
|
||||
modifyRequests(activeRequests, 'decline');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
active4kRequest &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
mediaType === 'movie'
|
||||
) {
|
||||
buttons.push(
|
||||
{
|
||||
id: 'approve-4k-request',
|
||||
text: intl.formatMessage(messages.approverequest4k),
|
||||
action: () => {
|
||||
modifyRequest(active4kRequest, 'approve');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'decline-4k-request',
|
||||
text: intl.formatMessage(messages.declinerequest4k),
|
||||
action: () => {
|
||||
modifyRequest(active4kRequest, 'decline');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
active4kRequests &&
|
||||
active4kRequests.length > 0 &&
|
||||
hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||
mediaType === 'tv'
|
||||
) {
|
||||
buttons.push(
|
||||
{
|
||||
id: 'approve-request-batch',
|
||||
text: intl.formatMessage(messages.approve4krequests, {
|
||||
requestCount: active4kRequests.length,
|
||||
}),
|
||||
action: () => {
|
||||
modifyRequests(active4kRequests, 'approve');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'decline-request-batch',
|
||||
text: intl.formatMessage(messages.decline4krequests, {
|
||||
requestCount: active4kRequests.length,
|
||||
}),
|
||||
action: () => {
|
||||
modifyRequests(active4kRequests, 'decline');
|
||||
},
|
||||
svg: (
|
||||
<svg
|
||||
className="w-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const [buttonOne, ...others] = buttons;
|
||||
|
||||
if (!buttonOne) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RequestModal
|
||||
tmdbId={tmdbId}
|
||||
show={showRequestModal}
|
||||
type={mediaType}
|
||||
onComplete={() => {
|
||||
onUpdate();
|
||||
setShowRequestModal(false);
|
||||
}}
|
||||
onCancel={() => setShowRequestModal(false)}
|
||||
/>
|
||||
<RequestModal
|
||||
tmdbId={tmdbId}
|
||||
show={showRequest4kModal}
|
||||
type={mediaType}
|
||||
is4k
|
||||
onComplete={() => {
|
||||
onUpdate();
|
||||
setShowRequest4kModal(false);
|
||||
}}
|
||||
onCancel={() => setShowRequest4kModal(false)}
|
||||
/>
|
||||
<ButtonWithDropdown
|
||||
text={
|
||||
<>
|
||||
{buttonOne.svg ?? null}
|
||||
{buttonOne.text}
|
||||
</>
|
||||
}
|
||||
onClick={buttonOne.action}
|
||||
className="ml-2"
|
||||
>
|
||||
{others && others.length > 0
|
||||
? others.map((button) => (
|
||||
<ButtonWithDropdown.Item
|
||||
onClick={button.action}
|
||||
key={`request-option-${button.id}`}
|
||||
>
|
||||
{button.svg}
|
||||
{button.text}
|
||||
</ButtonWithDropdown.Item>
|
||||
))
|
||||
: null}
|
||||
</ButtonWithDropdown>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RequestButton;
|
@ -0,0 +1,312 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { SmallLoadingSpinner } from '../../Common/LoadingSpinner';
|
||||
import type {
|
||||
ServiceCommonServer,
|
||||
ServiceCommonServerWithDetails,
|
||||
} from '../../../../server/interfaces/api/serviceInterfaces';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const formatBytes = (bytes: number, decimals = 2) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
advancedoptions: 'Advanced Options',
|
||||
destinationserver: 'Destination Server',
|
||||
qualityprofile: 'Quality Profile',
|
||||
rootfolder: 'Root Folder',
|
||||
animenote: '* This series is an anime.',
|
||||
default: '(Default)',
|
||||
loadingprofiles: 'Loading profiles…',
|
||||
loadingfolders: 'Loading folders…',
|
||||
});
|
||||
|
||||
export type RequestOverrides = {
|
||||
server?: number;
|
||||
profile?: number;
|
||||
folder?: string;
|
||||
};
|
||||
|
||||
interface AdvancedRequesterProps {
|
||||
type: 'movie' | 'tv';
|
||||
is4k: boolean;
|
||||
isAnime?: boolean;
|
||||
defaultOverrides?: RequestOverrides;
|
||||
onChange: (overrides: RequestOverrides) => void;
|
||||
}
|
||||
|
||||
const AdvancedRequester: React.FC<AdvancedRequesterProps> = ({
|
||||
type,
|
||||
is4k = false,
|
||||
isAnime = false,
|
||||
defaultOverrides,
|
||||
onChange,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { data, error } = useSWR<ServiceCommonServer[]>(
|
||||
`/api/v1/service/${type === 'movie' ? 'radarr' : 'sonarr'}`,
|
||||
{
|
||||
refreshInterval: 0,
|
||||
refreshWhenHidden: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnMount: true,
|
||||
}
|
||||
);
|
||||
const [selectedServer, setSelectedServer] = useState<number | null>(
|
||||
defaultOverrides?.server !== undefined && defaultOverrides?.server >= 0
|
||||
? defaultOverrides?.server
|
||||
: null
|
||||
);
|
||||
const [selectedProfile, setSelectedProfile] = useState<number>(
|
||||
defaultOverrides?.profile ?? -1
|
||||
);
|
||||
const [selectedFolder, setSelectedFolder] = useState<string>(
|
||||
defaultOverrides?.folder ?? ''
|
||||
);
|
||||
const {
|
||||
data: serverData,
|
||||
isValidating,
|
||||
} = useSWR<ServiceCommonServerWithDetails>(
|
||||
selectedServer !== null
|
||||
? `/api/v1/service/${
|
||||
type === 'movie' ? 'radarr' : 'sonarr'
|
||||
}/${selectedServer}`
|
||||
: null,
|
||||
{
|
||||
refreshInterval: 0,
|
||||
refreshWhenHidden: false,
|
||||
revalidateOnFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let defaultServer = data?.find(
|
||||
(server) => server.isDefault && is4k === server.is4k
|
||||
);
|
||||
|
||||
if (!defaultServer && (data ?? []).length > 0) {
|
||||
defaultServer = data?.[0];
|
||||
}
|
||||
|
||||
if (
|
||||
defaultServer &&
|
||||
defaultServer.id !== selectedServer &&
|
||||
(!defaultOverrides || defaultOverrides.server === null)
|
||||
) {
|
||||
setSelectedServer(defaultServer.id);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverData) {
|
||||
const defaultProfile = serverData.profiles.find(
|
||||
(profile) =>
|
||||
profile.id ===
|
||||
(isAnime
|
||||
? serverData.server.activeAnimeProfileId
|
||||
: serverData.server.activeProfileId)
|
||||
);
|
||||
const defaultFolder = serverData.rootFolders.find(
|
||||
(folder) =>
|
||||
folder.path ===
|
||||
(isAnime
|
||||
? serverData.server.activeAnimeDirectory
|
||||
: serverData.server.activeDirectory)
|
||||
);
|
||||
|
||||
if (
|
||||
defaultProfile &&
|
||||
defaultProfile.id !== selectedProfile &&
|
||||
(!defaultOverrides || defaultOverrides.profile === null)
|
||||
) {
|
||||
setSelectedProfile(defaultProfile.id);
|
||||
}
|
||||
|
||||
if (
|
||||
defaultFolder &&
|
||||
defaultFolder.path !== selectedFolder &&
|
||||
(!defaultOverrides || defaultOverrides.folder === null)
|
||||
) {
|
||||
setSelectedFolder(defaultFolder?.path ?? '');
|
||||
}
|
||||
}
|
||||
}, [serverData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
defaultOverrides &&
|
||||
defaultOverrides.server !== null &&
|
||||
defaultOverrides.server !== undefined
|
||||
) {
|
||||
setSelectedServer(defaultOverrides.server);
|
||||
}
|
||||
|
||||
if (
|
||||
defaultOverrides &&
|
||||
defaultOverrides.profile !== null &&
|
||||
defaultOverrides.profile !== undefined
|
||||
) {
|
||||
setSelectedProfile(defaultOverrides.profile);
|
||||
}
|
||||
|
||||
if (
|
||||
defaultOverrides &&
|
||||
defaultOverrides.folder !== null &&
|
||||
defaultOverrides.folder !== undefined
|
||||
) {
|
||||
setSelectedFolder(defaultOverrides.folder);
|
||||
}
|
||||
}, [
|
||||
defaultOverrides?.server,
|
||||
defaultOverrides?.folder,
|
||||
defaultOverrides?.profile,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedServer !== null) {
|
||||
onChange({
|
||||
folder: selectedFolder !== '' ? selectedFolder : undefined,
|
||||
profile: selectedProfile !== -1 ? selectedProfile : undefined,
|
||||
server: selectedServer ?? undefined,
|
||||
});
|
||||
}
|
||||
}, [selectedFolder, selectedServer, selectedProfile]);
|
||||
|
||||
if (!data && !error) {
|
||||
return (
|
||||
<div className="w-full mb-2">
|
||||
<SmallLoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || selectedServer === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center mb-2 font-bold tracking-wider">
|
||||
<svg
|
||||
className="w-4 h-4 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M9.707 7.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L13 8.586V5h3a2 2 0 012 2v5a2 2 0 01-2 2H8a2 2 0 01-2-2V7a2 2 0 012-2h3v3.586L9.707 7.293zM11 3a1 1 0 112 0v2h-2V3z" />
|
||||
<path d="M4 9a2 2 0 00-2 2v5a2 2 0 002 2h8a2 2 0 002-2H4V9z" />
|
||||
</svg>
|
||||
{intl.formatMessage(messages.advancedoptions)}
|
||||
</div>
|
||||
<div className="p-4 bg-gray-600 rounded-md shadow">
|
||||
<div className="flex flex-col items-center justify-between md:flex-row">
|
||||
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/3 md:pr-4 md:mb-0">
|
||||
<label htmlFor="server" className="block text-sm font-medium">
|
||||
{intl.formatMessage(messages.destinationserver)}
|
||||
</label>
|
||||
<select
|
||||
id="server"
|
||||
name="server"
|
||||
onChange={(e) => setSelectedServer(Number(e.target.value))}
|
||||
onBlur={(e) => setSelectedServer(Number(e.target.value))}
|
||||
value={selectedServer}
|
||||
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
|
||||
>
|
||||
{data.map((server) => (
|
||||
<option key={`server-list-${server.id}`} value={server.id}>
|
||||
{server.name}
|
||||
{server.isDefault && server.is4k === is4k
|
||||
? ` ${intl.formatMessage(messages.default)}`
|
||||
: ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/3 md:pr-4 md:mb-0">
|
||||
<label htmlFor="server" className="block text-sm font-medium">
|
||||
{intl.formatMessage(messages.qualityprofile)}
|
||||
</label>
|
||||
<select
|
||||
id="profile"
|
||||
name="profile"
|
||||
value={selectedProfile}
|
||||
onChange={(e) => setSelectedProfile(Number(e.target.value))}
|
||||
onBlur={(e) => setSelectedProfile(Number(e.target.value))}
|
||||
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
|
||||
>
|
||||
{isValidating && (
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.loadingprofiles)}
|
||||
</option>
|
||||
)}
|
||||
{!isValidating &&
|
||||
serverData &&
|
||||
serverData.profiles.map((profile) => (
|
||||
<option key={`profile-list${profile.id}`} value={profile.id}>
|
||||
{profile.name}
|
||||
{isAnime &&
|
||||
serverData.server.activeAnimeProfileId === profile.id
|
||||
? ` ${intl.formatMessage(messages.default)}`
|
||||
: !isAnime &&
|
||||
serverData.server.activeProfileId === profile.id
|
||||
? ` ${intl.formatMessage(messages.default)}`
|
||||
: ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-grow flex-shrink-0 w-full mb-2 md:w-1/3 md:mb-0">
|
||||
<label htmlFor="server" className="block text-sm font-medium">
|
||||
{intl.formatMessage(messages.rootfolder)}
|
||||
</label>
|
||||
<select
|
||||
id="folder"
|
||||
name="folder"
|
||||
value={selectedFolder}
|
||||
onChange={(e) => setSelectedFolder(e.target.value)}
|
||||
onBlur={(e) => setSelectedFolder(e.target.value)}
|
||||
className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5"
|
||||
>
|
||||
{isValidating && (
|
||||
<option value="">
|
||||
{intl.formatMessage(messages.loadingfolders)}
|
||||
</option>
|
||||
)}
|
||||
{!isValidating &&
|
||||
serverData &&
|
||||
serverData.rootFolders.map((folder) => (
|
||||
<option key={`folder-list${folder.id}`} value={folder.path}>
|
||||
{folder.path} ({formatBytes(folder.freeSpace ?? 0)})
|
||||
{isAnime &&
|
||||
serverData.server.activeAnimeDirectory === folder.path
|
||||
? ` ${intl.formatMessage(messages.default)}`
|
||||
: !isAnime &&
|
||||
serverData.server.activeDirectory === folder.path
|
||||
? ` ${intl.formatMessage(messages.default)}`
|
||||
: ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{isAnime && (
|
||||
<div className="mt-4 italic">
|
||||
{intl.formatMessage(messages.animenote)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedRequester;
|
@ -0,0 +1,337 @@
|
||||
import React from 'react';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import dynamic from 'next/dynamic';
|
||||
import useSWR from 'swr';
|
||||
import LoadingSpinner from '../../../Common/LoadingSpinner';
|
||||
import Button from '../../../Common/Button';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
import axios from 'axios';
|
||||
import * as Yup from 'yup';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import NotificationTypeSelector from '../../../NotificationTypeSelector';
|
||||
|
||||
const JSONEditor = dynamic(() => import('../../../JSONEditor'), { ssr: false });
|
||||
|
||||
const defaultPayload = {
|
||||
notification_type: '{{notification_type}}',
|
||||
subject: '{{subject}}',
|
||||
message: '{{message}}',
|
||||
image: '{{image}}',
|
||||
email: '{{notifyuser_email}}',
|
||||
username: '{{notifyuser_username}}',
|
||||
avatar: '{{notifyuser_avatar}}',
|
||||
'{{media}}': {
|
||||
media_type: '{{media_type}}',
|
||||
tmdbId: '{{media_tmdbid}}',
|
||||
imdbId: '{{media_imdbid}}',
|
||||
tvdbId: '{{media_tvdbid}}',
|
||||
status: '{{media_status}}',
|
||||
status4k: '{{media_status4k}}',
|
||||
},
|
||||
'{{extra}}': [],
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
save: 'Save Changes',
|
||||
saving: 'Saving...',
|
||||
agentenabled: 'Agent Enabled',
|
||||
webhookUrl: 'Webhook URL',
|
||||
authheader: 'Authorization Header',
|
||||
validationWebhookUrlRequired: 'You must provide a webhook URL',
|
||||
validationJsonPayloadRequired: 'You must provide a JSON Payload',
|
||||
webhookUrlPlaceholder: 'Remote webhook URL',
|
||||
webhooksettingssaved: 'Webhook notification settings saved!',
|
||||
webhooksettingsfailed: 'Webhook notification settings failed to save.',
|
||||
testsent: 'Test notification sent!',
|
||||
test: 'Test',
|
||||
notificationtypes: 'Notification Types',
|
||||
resetPayload: 'Reset to Default JSON Payload',
|
||||
resetPayloadSuccess: 'JSON reset to default payload.',
|
||||
customJson: 'Custom JSON Payload',
|
||||
templatevariablehelp: 'Template Variable Help',
|
||||
});
|
||||
|
||||
const NotificationsWebhook: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { addToast } = useToasts();
|
||||
const { data, error, revalidate } = useSWR(
|
||||
'/api/v1/settings/notifications/webhook'
|
||||
);
|
||||
|
||||
const NotificationsWebhookSchema = Yup.object().shape({
|
||||
webhookUrl: Yup.string().required(
|
||||
intl.formatMessage(messages.validationWebhookUrlRequired)
|
||||
),
|
||||
jsonPayload: Yup.string()
|
||||
.required(intl.formatMessage(messages.validationJsonPayloadRequired))
|
||||
.test('validate-json', 'Invalid JSON', (value) => {
|
||||
try {
|
||||
JSON.parse(value ?? '');
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
if (!data && !error) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
enabled: data.enabled,
|
||||
types: data.types,
|
||||
webhookUrl: data.options.webhookUrl,
|
||||
jsonPayload: data.options.jsonPayload,
|
||||
authHeader: data.options.authHeader,
|
||||
}}
|
||||
validationSchema={NotificationsWebhookSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post('/api/v1/settings/notifications/webhook', {
|
||||
enabled: values.enabled,
|
||||
types: values.types,
|
||||
options: {
|
||||
webhookUrl: values.webhookUrl,
|
||||
jsonPayload: JSON.stringify(values.jsonPayload),
|
||||
authHeader: values.authHeader,
|
||||
},
|
||||
});
|
||||
addToast(intl.formatMessage(messages.webhooksettingssaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} catch (e) {
|
||||
addToast(intl.formatMessage(messages.webhooksettingsfailed), {
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} finally {
|
||||
revalidate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({
|
||||
errors,
|
||||
touched,
|
||||
isSubmitting,
|
||||
values,
|
||||
isValid,
|
||||
setFieldValue,
|
||||
setFieldTouched,
|
||||
}) => {
|
||||
const resetPayload = () => {
|
||||
setFieldValue(
|
||||
'jsonPayload',
|
||||
JSON.stringify(defaultPayload, undefined, ' ')
|
||||
);
|
||||
addToast(intl.formatMessage(messages.resetPayloadSuccess), {
|
||||
appearance: 'info',
|
||||
autoDismiss: true,
|
||||
});
|
||||
};
|
||||
|
||||
const testSettings = async () => {
|
||||
await axios.post('/api/v1/settings/notifications/webhook/test', {
|
||||
enabled: true,
|
||||
types: values.types,
|
||||
options: {
|
||||
webhookUrl: values.webhookUrl,
|
||||
jsonPayload: JSON.stringify(values.jsonPayload),
|
||||
authHeader: values.authHeader,
|
||||
},
|
||||
});
|
||||
|
||||
addToast(intl.formatMessage(messages.testsent), {
|
||||
appearance: 'info',
|
||||
autoDismiss: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200">
|
||||
<label
|
||||
htmlFor="enabled"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.agentenabled)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="enabled"
|
||||
name="enabled"
|
||||
className="w-6 h-6 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.webhookUrl)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="webhookUrl"
|
||||
name="webhookUrl"
|
||||
type="text"
|
||||
placeholder={intl.formatMessage(
|
||||
messages.webhookUrlPlaceholder
|
||||
)}
|
||||
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
{errors.webhookUrl && touched.webhookUrl && (
|
||||
<div className="mt-2 text-red-500">{errors.webhookUrl}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.authheader)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<Field
|
||||
id="authHeader"
|
||||
name="authHeader"
|
||||
type="text"
|
||||
className="flex-1 block w-full min-w-0 transition duration-150 ease-in-out bg-gray-700 border border-gray-500 rounded-md form-input sm:text-sm sm:leading-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800">
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px"
|
||||
>
|
||||
{intl.formatMessage(messages.customJson)}
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<div className="flex max-w-lg rounded-md shadow-sm">
|
||||
<JSONEditor
|
||||
name="webhook-json-payload"
|
||||
onUpdate={(value) => setFieldValue('jsonPayload', value)}
|
||||
value={values.jsonPayload}
|
||||
onBlur={() => setFieldTouched('jsonPayload')}
|
||||
/>
|
||||
</div>
|
||||
{errors.jsonPayload && touched.jsonPayload && (
|
||||
<div className="mt-2 text-red-500">{errors.jsonPayload}</div>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<Button
|
||||
buttonSize="sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
resetPayload();
|
||||
}}
|
||||
className="mr-2"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{intl.formatMessage(messages.resetPayload)}
|
||||
</Button>
|
||||
<a
|
||||
href="https://docs.overseerr.dev/using-overseerr/notifications/webhooks#template-variables"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center justify-center font-medium leading-5 text-white transition duration-150 ease-in-out bg-indigo-600 border border-transparent rounded-md focus:outline-none hover:bg-indigo-500 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 disabled:opacity-50 px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 00-.867.5 1 1 0 11-1.731-1A3 3 0 0113 8a3.001 3.001 0 01-2 2.83V11a1 1 0 11-2 0v-1a1 1 0 011-1 1 1 0 100-2zm0 8a1 1 0 100-2 1 1 0 000 2z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{intl.formatMessage(messages.templatevariablehelp)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<div role="group" aria-labelledby="label-permissions">
|
||||
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-baseline">
|
||||
<div>
|
||||
<div
|
||||
className="text-base font-medium leading-6 text-gray-400 sm:text-sm sm:leading-5"
|
||||
id="label-types"
|
||||
>
|
||||
{intl.formatMessage(messages.notificationtypes)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 sm:mt-0 sm:col-span-2">
|
||||
<div className="max-w-lg">
|
||||
<NotificationTypeSelector
|
||||
currentTypes={values.types}
|
||||
onUpdate={(newTypes) =>
|
||||
setFieldValue('types', newTypes)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-5 mt-8 border-t border-gray-700">
|
||||
<div className="flex justify-end">
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="warning"
|
||||
disabled={isSubmitting || !isValid}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
testSettings();
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage(messages.test)}
|
||||
</Button>
|
||||
</span>
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
type="submit"
|
||||
disabled={isSubmitting || !isValid}
|
||||
>
|
||||
{isSubmitting
|
||||
? intl.formatMessage(messages.saving)
|
||||
: intl.formatMessage(messages.save)}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsWebhook;
|
@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { PublicSettingsResponse } from '../../server/interfaces/api/settingsInterfaces';
|
||||
import useSWR from 'swr';
|
||||
|
||||
interface SettingsContextProps {
|
||||
currentSettings: PublicSettingsResponse;
|
||||
}
|
||||
|
||||
const defaultSettings = {
|
||||
initialized: false,
|
||||
movie4kEnabled: false,
|
||||
series4kEnabled: false,
|
||||
};
|
||||
|
||||
export const SettingsContext = React.createContext<SettingsContextProps>({
|
||||
currentSettings: defaultSettings,
|
||||
});
|
||||
|
||||
export const SettingsProvider: React.FC<SettingsContextProps> = ({
|
||||
children,
|
||||
currentSettings,
|
||||
}) => {
|
||||
const { data, error } = useSWR<PublicSettingsResponse>(
|
||||
'/api/v1/settings/public',
|
||||
{ initialData: currentSettings }
|
||||
);
|
||||
|
||||
let newSettings = defaultSettings;
|
||||
|
||||
if (data && !error) {
|
||||
newSettings = data;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={{ currentSettings: newSettings }}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
};
|
@ -0,0 +1,46 @@
|
||||
import useSWR from 'swr';
|
||||
import { MediaRequest } from '../../server/entity/MediaRequest';
|
||||
import { ServiceCommonServer } from '../../server/interfaces/api/serviceInterfaces';
|
||||
|
||||
interface OverrideStatus {
|
||||
server: string | null;
|
||||
profile: number | null;
|
||||
rootFolder: string | null;
|
||||
}
|
||||
|
||||
const useRequestOverride = (request: MediaRequest): OverrideStatus => {
|
||||
const { data } = useSWR<ServiceCommonServer[]>(
|
||||
`/api/v1/service/${request.type === 'movie' ? 'radarr' : 'sonarr'}`
|
||||
);
|
||||
|
||||
if (!data) {
|
||||
return {
|
||||
server: null,
|
||||
profile: null,
|
||||
rootFolder: null,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultServer = data.find(
|
||||
(server) => server.is4k === request.is4k && server.isDefault
|
||||
);
|
||||
|
||||
const activeServer = data.find((server) => server.id === request.serverId);
|
||||
|
||||
return {
|
||||
server:
|
||||
activeServer && request.serverId !== defaultServer?.id
|
||||
? activeServer.name
|
||||
: null,
|
||||
profile:
|
||||
defaultServer?.activeProfileId !== request.profileId
|
||||
? request.profileId
|
||||
: null,
|
||||
rootFolder:
|
||||
defaultServer?.activeDirectory !== request.rootFolder
|
||||
? request.rootFolder
|
||||
: null,
|
||||
};
|
||||
};
|
||||
|
||||
export default useRequestOverride;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue