Merge branch 'develop'

pull/570/head
sct 4 years ago
commit f99ab47c01

@ -216,6 +216,25 @@
"contributions": [
"code"
]
},
{
"login": "ankarhem",
"name": "Jakob Ankarhem",
"avatar_url": "https://avatars1.githubusercontent.com/u/14110063?v=4",
"profile": "https://github.com/ankarhem",
"contributions": [
"doc",
"code"
]
},
{
"login": "jayesh100",
"name": "Jayesh",
"avatar_url": "https://avatars1.githubusercontent.com/u/8022175?v=4",
"profile": "https://github.com/jayesh100",
"contributions": [
"code"
]
}
],
"badgeTemplate": "<a href=\"#contributors-\"><img alt=\"All Contributors\" src=\"https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg\"/></a>",

1
.gitignore vendored

@ -37,6 +37,7 @@ config/settings.json
# logs
config/logs/*.log*
config/logs/*.json
# dist files
dist

@ -14,16 +14,46 @@ All help is welcome and greatly appreciated. If you would like to contribute to
### Getting Started
1. [Fork](https://help.github.com/articles/fork-a-repo/) the repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
2. Create a new branch `git checkout -b BRANCH_NAME`
```
git clone https://github.com/YOUR_USERNAME/overseerr.git
cd overseerr/
```
2. Add the remote upstream.
- Its recommended to name the branch something relevant to the feature or fix you are working on.
- An example of this would be `fix-title-cards` or `feature-new-system`.
- Bad examples would be `patch` or `bug`.
```
git remote add upstream https://github.com/sct/overseerr.git
```
3. Install dependencies `yarn`
4. `yarn dev` to build and watch for changes
3. Create a new branch
You can also run the development environment in [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
```
git checkout -b BRANCH_NAME develop
```
- Its recommended to name the branch something relevant to the feature or fix you are working on.
- An example of this would be `fix-title-cards` or `feature-new-system`.
- Bad examples would be `patch` or `bug`.
4. Run development environment
```
yarn
yarn install
```
- Alternatively you can run using [Docker](https://www.docker.com/) with `docker-compose up -d`. This method does not require installing NodeJS or Yarn on your machine directly.
5. Create your patch and run appropriate tests.
6. Follow the [guidelines](#contributing-code).
7. Should you need to update your fork you can do so by rebasing from upstream.
```
git fetch upstream
git rebase upstream/develop
git push origin BRANCH_NAME -f
```
### Contributing Code

@ -16,7 +16,7 @@
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
<img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr">
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-23-orange.svg"/></a>
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-25-orange.svg"/></a>
<!-- ALL-CONTRIBUTORS-BADGE:END -->
</p>
@ -128,6 +128,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<tr>
<td align="center"><a href="https://github.com/doob187"><img src="https://avatars1.githubusercontent.com/u/60312740?v=4" width="100px;" alt=""/><br /><sub><b>doob187</b></sub></a><br /><a href="#infra-doob187" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/johnpyp"><img src="https://avatars2.githubusercontent.com/u/20625636?v=4" width="100px;" alt=""/><br /><sub><b>johnpyp</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=johnpyp" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/ankarhem"><img src="https://avatars1.githubusercontent.com/u/14110063?v=4" width="100px;" alt=""/><br /><sub><b>Jakob Ankarhem</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Documentation">📖</a> <a href="https://github.com/sct/overseerr/commits?author=ankarhem" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/jayesh100"><img src="https://avatars1.githubusercontent.com/u/8022175?v=4" width="100px;" alt=""/><br /><sub><b>Jayesh</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=jayesh100" title="Code">💻</a></td>
</tr>
</table>

@ -383,6 +383,36 @@ components:
type: string
name:
type: string
RelatedVideo:
type: object
properties:
url:
type: string
example: https://www.youtube.com/watch?v=9qhL2_UxXM0/
key:
type: string
example: 9qhL2_UxXM0
name:
type: string
example: Trailer for some movie (1978)
size:
type: number
example: 1080
type:
type: string
example: Trailer
enum:
- Clip
- Teaser
- Trailer
- Featurette
- Opening Credits
- Behind the Scenes
- Bloopers
site:
type: string
enum:
- 'YouTube'
MovieDetails:
type: object
properties:
@ -408,6 +438,10 @@ components:
$ref: '#/components/schemas/Genre'
homepage:
type: string
relatedVideos:
type: array
items:
$ref: '#/components/schemas/RelatedVideo'
originalLanguage:
type: string
originalTitle:
@ -463,6 +497,19 @@ components:
type: array
items:
$ref: '#/components/schemas/Crew'
collection:
type: object
properties:
id:
type: number
example: 1
name:
type: string
example: A collection
posterPath:
type: string
backdropPath:
type: string
externalIds:
$ref: '#/components/schemas/ExternalIds'
mediaInfo:
@ -794,6 +841,20 @@ components:
properties:
webhookUrl:
type: string
SlackSettings:
type: object
properties:
enabled:
type: boolean
example: false
types:
type: number
example: 2
options:
type: object
properties:
webhookUrl:
type: string
NotificationEmailSettings:
type: object
properties:
@ -991,6 +1052,26 @@ components:
name:
type: string
example: 'English'
Collection:
type: object
properties:
id:
type: number
example: 123
name:
type: string
example: A Movie Collection
overview:
type: string
example: Overview of collection
posterPath:
type: string
backdropPath:
type: string
parts:
type: array
items:
$ref: '#/components/schemas/MovieResult'
securitySchemes:
cookieAuth:
type: apiKey
@ -1554,6 +1635,52 @@ paths:
responses:
'204':
description: Test notification attempted
/settings/notifications/slack:
get:
summary: Return current slack notification settings
description: Returns current slack notification settings in JSON format
tags:
- settings
responses:
'200':
description: Returned slack settings
content:
application/json:
schema:
$ref: '#/components/schemas/SlackSettings'
post:
summary: Update slack notification settings
description: Update current slack notification settings with provided values
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SlackSettings'
responses:
'200':
description: 'Values were sucessfully updated'
content:
application/json:
schema:
$ref: '#/components/schemas/SlackSettings'
/settings/notifications/slack/test:
post:
summary: Test the provided slack settings
description: Sends a test notification to the slack agent
tags:
- settings
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/SlackSettings'
responses:
'204':
description: Test notification attempted
/settings/about:
get:
summary: Return current about stats
@ -1691,7 +1818,8 @@ paths:
application/json:
schema:
type: array
$ref: '#/components/schemas/User'
items:
$ref: '#/components/schemas/User'
/user/{userId}:
get:
@ -2132,6 +2260,30 @@ paths:
responses:
'204':
description: Succesfully removed request
/request/{requestId}/retry:
post:
summary: Retry a failed request
description: |
Retries a request by resending requests to Sonarr or Radarr
Requires the `MANAGE_REQUESTS` permission or `ADMIN`
tags:
- request
parameters:
- in: path
name: requestId
description: Request ID
required: true
schema:
type: string
example: 1
responses:
'200':
description: Retry triggered
content:
application/json:
schema:
$ref: '#/components/schemas/MediaRequest'
/request/{requestId}/{status}:
get:
summary: Update a requests status
@ -2626,6 +2778,31 @@ paths:
responses:
'204':
description: Succesfully removed media item
/collection/{collectionId}:
get:
summary: Request collection details
description: Returns back full collection details in JSON format
tags:
- collection
parameters:
- in: path
name: collectionId
required: true
schema:
type: number
example: 537982
- in: query
name: language
schema:
type: string
example: en
responses:
'200':
description: Collection details
content:
application/json:
schema:
$ref: '#/components/schemas/Collection'
security:
- cookieAuth: []

@ -53,6 +53,7 @@
"typeorm": "^0.2.29",
"uuid": "^8.3.2",
"winston": "^3.3.3",
"winston-daily-rotate-file": "^4.5.0",
"xml2js": "^0.4.23",
"yamljs": "^0.3.0",
"yup": "^0.32.8"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 26 KiB

@ -14,7 +14,7 @@
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#1e2937",
"theme_color": "#2d3748",
"background_color": "#2d3748",
"display": "standalone"
}

@ -76,7 +76,7 @@ class RadarrAPI {
}
};
public addMovie = async (options: RadarrMovieOptions): Promise<void> => {
public addMovie = async (options: RadarrMovieOptions): Promise<boolean> => {
try {
const response = await this.axios.post<RadarrMovie>(`/movie`, {
title: options.title,
@ -104,7 +104,9 @@ class RadarrAPI {
label: 'Radarr',
options,
});
return false;
}
return true;
} catch (e) {
logger.error(
'Failed to add movie to Radarr. This might happen if the movie already exists, in which case you can safely ignore this error.',
@ -112,8 +114,13 @@ class RadarrAPI {
label: 'Radarr',
errorMessage: e.message,
options,
response: e?.response?.data,
}
);
if (e?.response?.data?.[0]?.errorCode === 'MovieExistsValidator') {
return true;
}
return false;
}
};

@ -116,7 +116,7 @@ class SonarrAPI {
}
}
public async addSeries(options: AddSeriesOptions): Promise<SonarrSeries> {
public async addSeries(options: AddSeriesOptions): Promise<boolean> {
try {
const series = await this.getSeriesByTvdbId(options.tvdbid);
@ -147,9 +147,10 @@ class SonarrAPI {
label: 'Sonarr',
options,
});
return false;
}
return newSeriesResponse.data;
return true;
}
const createdSeriesResponse = await this.axios.post<SonarrSeries>(
@ -188,16 +189,18 @@ class SonarrAPI {
label: 'Sonarr',
options,
});
return false;
}
return createdSeriesResponse.data;
return true;
} catch (e) {
logger.error('Something went wrong adding a series to Sonarr', {
label: 'Sonarr API',
errorMessage: e.message,
error: e,
response: e?.response?.data,
});
throw new Error('Failed to add series');
return false;
}
}

@ -190,7 +190,30 @@ export interface TmdbMovieDetails {
cast: TmdbCreditCast[];
crew: TmdbCreditCrew[];
};
belongs_to_collection?: {
id: number;
name: string;
poster_path?: string;
backdrop_path?: string;
};
external_ids: TmdbExternalIds;
videos: TmdbVideoResult;
}
export interface TmdbVideo {
id: string;
key: string;
name: string;
site: 'YouTube';
size: number;
type:
| 'Clip'
| 'Teaser'
| 'Trailer'
| 'Featurette'
| 'Opening Credits'
| 'Behind the Scenes'
| 'Bloopers';
}
export interface TmdbTvEpisodeResult {
@ -278,6 +301,11 @@ export interface TmdbTvDetails {
keywords: {
results: TmdbKeyword[];
};
videos: TmdbVideoResult;
}
export interface TmdbVideoResult {
results: TmdbVideo[];
}
export interface TmdbKeyword {
@ -344,6 +372,15 @@ export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult {
external_ids: TmdbExternalIds;
}
export interface TmdbCollection {
id: number;
name: string;
overview?: string;
poster_path?: string;
backdrop_path?: string;
parts: TmdbMovieResult[];
}
class TheMovieDb {
private apiKey = 'db55323b8d3e4154498498a75642b381';
private axios: AxiosInstance;
@ -438,7 +475,10 @@ class TheMovieDb {
const response = await this.axios.get<TmdbMovieDetails>(
`/movie/${movieId}`,
{
params: { language, append_to_response: 'credits,external_ids' },
params: {
language,
append_to_response: 'credits,external_ids,videos',
},
}
);
@ -459,7 +499,7 @@ class TheMovieDb {
const response = await this.axios.get<TmdbTvDetails>(`/tv/${tvId}`, {
params: {
language,
append_to_response: 'credits,external_ids,keywords',
append_to_response: 'credits,external_ids,keywords,videos',
},
});
@ -866,6 +906,29 @@ class TheMovieDb {
);
}
}
public async getCollection({
collectionId,
language = 'en-US',
}: {
collectionId: number;
language?: string;
}): Promise<TmdbCollection> {
try {
const response = await this.axios.get<TmdbCollection>(
`/collection/${collectionId}`,
{
params: {
language,
},
}
);
return response.data;
} catch (e) {
throw new Error(`[TMDB] Failed to fetch collection: ${e.message}`);
}
}
}
export default TheMovieDb;

@ -69,6 +69,12 @@ export class MediaRequest {
Object.assign(this, init);
}
@AfterUpdate()
@AfterInsert()
public async sendMedia(): Promise<void> {
await Promise.all([this._sendToRadarr(), this._sendToSonarr()]);
}
@AfterInsert()
private async _notifyNewRequest() {
if (this.status === MediaRequestStatus.PENDING) {
@ -163,7 +169,7 @@ export class MediaRequest {
@AfterUpdate()
@AfterInsert()
private async _updateParentStatus() {
public async updateParentStatus(): Promise<void> {
const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({
where: { id: this.media.id },
@ -229,14 +235,13 @@ export class MediaRequest {
}
}
@AfterUpdate()
@AfterInsert()
private async _sendToRadarr() {
if (
this.status === MediaRequestStatus.APPROVED &&
this.type === MediaType.MOVIE
) {
try {
const mediaRepository = getRepository(Media);
const settings = getSettings();
if (settings.radarr.length === 0 && !settings.radarr[0]) {
logger.info(
@ -268,17 +273,49 @@ export class MediaRequest {
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
// Run this asynchronously so we don't wait for it on the UI side
radarr.addMovie({
profileId: radarrSettings.activeProfileId,
qualityProfileId: radarrSettings.activeProfileId,
rootFolderPath: radarrSettings.activeDirectory,
minimumAvailability: radarrSettings.minimumAvailability,
title: movie.title,
tmdbId: movie.id,
year: Number(movie.release_date.slice(0, 4)),
monitored: true,
searchNow: true,
});
radarr
.addMovie({
profileId: radarrSettings.activeProfileId,
qualityProfileId: radarrSettings.activeProfileId,
rootFolderPath: radarrSettings.activeDirectory,
minimumAvailability: radarrSettings.minimumAvailability,
title: movie.title,
tmdbId: movie.id,
year: Number(movie.release_date.slice(0, 4)),
monitored: true,
searchNow: true,
})
.then(async (success) => {
if (!success) {
const media = await mediaRepository.findOne({
where: { id: this.media.id },
});
if (!media) {
logger.error('Media not present');
return;
}
media.status = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
logger.warn(
'Newly added movie request failed to add to Radarr, marking as unknown',
{
label: 'Media Request',
}
);
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
select: ['id', 'plexToken'],
order: { id: 'ASC' },
});
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
subject: movie.title,
message: 'Movie failed to add to Radarr',
notifyUser: admin,
media,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
});
}
});
logger.info('Sent request to Radarr', { label: 'Media Request' });
} catch (e) {
throw new Error(
@ -288,8 +325,6 @@ export class MediaRequest {
}
}
@AfterUpdate()
@AfterInsert()
private async _sendToSonarr() {
if (
this.status === MediaRequestStatus.APPROVED &&
@ -352,23 +387,55 @@ export class MediaRequest {
}
// Run this asynchronously so we don't wait for it on the UI side
sonarr.addSeries({
profileId:
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
? sonarrSettings.activeAnimeProfileId
: sonarrSettings.activeProfileId,
rootFolderPath:
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
? sonarrSettings.activeAnimeDirectory
: sonarrSettings.activeDirectory,
title: series.name,
tvdbid: series.external_ids.tvdb_id,
seasons: this.seasons.map((season) => season.seasonNumber),
seasonFolder: sonarrSettings.enableSeasonFolders,
seriesType,
monitored: true,
searchNow: true,
});
sonarr
.addSeries({
profileId:
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
? sonarrSettings.activeAnimeProfileId
: sonarrSettings.activeProfileId,
rootFolderPath:
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
? sonarrSettings.activeAnimeDirectory
: sonarrSettings.activeDirectory,
title: series.name,
tvdbid: series.external_ids.tvdb_id,
seasons: this.seasons.map((season) => season.seasonNumber),
seasonFolder: sonarrSettings.enableSeasonFolders,
seriesType,
monitored: true,
searchNow: true,
})
.then(async (success) => {
if (!success) {
media.status = MediaStatus.UNKNOWN;
await mediaRepository.save(media);
logger.warn(
'Newly added series request failed to add to Sonarr, marking as unknown',
{
label: 'Media Request',
}
);
const userRepository = getRepository(User);
const admin = await userRepository.findOneOrFail({
order: { id: 'ASC' },
});
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
subject: series.name,
message: 'Series failed to add to Sonarr',
notifyUser: admin,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${series.poster_path}`,
media,
extra: [
{
name: 'Seasons',
value: this.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
});
}
});
logger.info('Sent request to Sonarr', { label: 'Media Request' });
} catch (e) {
throw new Error(

@ -18,6 +18,7 @@ import notificationManager from './lib/notifications';
import DiscordAgent from './lib/notifications/agents/discord';
import EmailAgent from './lib/notifications/agents/email';
import { getAppVersion } from './utils/appVersion';
import SlackAgent from './lib/notifications/agents/slack';
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
@ -42,7 +43,11 @@ app
const settings = getSettings().load();
// Register Notification Agents
notificationManager.registerAgents([new DiscordAgent(), new EmailAgent()]);
notificationManager.registerAgents([
new DiscordAgent(),
new EmailAgent(),
new SlackAgent(),
]);
// Start Jobs
startJobs();

@ -158,6 +158,15 @@ class DiscordAgent
}
);
if (settings.main.applicationUrl) {
fields.push({
name: 'View Media',
value: `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
});
}
break;
case Notification.MEDIA_FAILED:
color = EmbedColors.RED;
if (settings.main.applicationUrl) {
fields.push({
name: 'View Media',

@ -112,6 +112,52 @@ class EmailAgent
}
}
private async sendMediaFailedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
try {
const userRepository = getRepository(User);
const users = await userRepository.find();
// Send to all users with the manage requests permission (or admins)
users
.filter((user) => user.hasPermission(Permission.MANAGE_REQUESTS))
.forEach((user) => {
const email = this.getNewEmail();
email.send({
template: path.join(
__dirname,
'../../../templates/email/media-request'
),
message: {
to: user.email,
},
locals: {
body:
"A user's new request has failed to add to Sonarr or Radarr",
mediaName: payload.subject,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.notifyUser.username,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
requestType: 'Failed Request',
},
});
});
return true;
} catch (e) {
logger.error('Mail notification failed to send', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
private async sendMediaApprovedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl;
@ -228,6 +274,9 @@ class EmailAgent
case Notification.MEDIA_AVAILABLE:
this.sendMediaAvailableEmail(payload);
break;
case Notification.MEDIA_FAILED:
this.sendMediaFailedEmail(payload);
break;
case Notification.TEST_NOTIFICATION:
this.sendTestEmail(payload);
break;

@ -0,0 +1,225 @@
import axios from 'axios';
import { Notification } from '..';
import logger from '../../../logger';
import { getSettings, NotificationAgentSlack } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface EmbedField {
type: 'plain_text' | 'mrkdwn';
text: string;
}
interface TextItem {
type: 'plain_text' | 'mrkdwn';
text: string;
emoji?: boolean;
}
interface Element {
type: 'button';
text?: TextItem;
value: string;
url: string;
action_id: 'button-action';
}
interface EmbedBlock {
type: 'header' | 'actions' | 'section' | 'context';
block_id?: 'section789';
text?: TextItem;
fields?: EmbedField[];
accessory?: {
type: 'image';
image_url: string;
alt_text: string;
};
elements?: Element[];
}
interface SlackBlockEmbed {
blocks: EmbedBlock[];
}
class SlackAgent
extends BaseAgent<NotificationAgentSlack>
implements NotificationAgent {
protected getSettings(): NotificationAgentSlack {
if (this.settings) {
return this.settings;
}
const settings = getSettings();
return settings.notifications.agents.slack;
}
public buildEmbed(
type: Notification,
payload: NotificationPayload
): SlackBlockEmbed {
const settings = getSettings();
let header = 'Overseerr';
let actionUrl: string | undefined;
const fields: EmbedField[] = [];
switch (type) {
case Notification.MEDIA_PENDING:
header = 'New Request';
fields.push(
{
type: 'mrkdwn',
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
},
{
type: 'mrkdwn',
text: '*Status*\nPending Approval',
}
);
if (settings.main.applicationUrl) {
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
}
break;
case Notification.MEDIA_APPROVED:
header = 'Request Approved';
fields.push(
{
type: 'mrkdwn',
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
},
{
type: 'mrkdwn',
text: '*Status*\nProcessing Request',
}
);
if (settings.main.applicationUrl) {
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
}
break;
case Notification.MEDIA_AVAILABLE:
header = 'Now available!';
fields.push(
{
type: 'mrkdwn',
text: `*Requested By*\n${payload.notifyUser.username ?? ''}`,
},
{
type: 'mrkdwn',
text: '*Status*\nAvailable',
}
);
if (settings.main.applicationUrl) {
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
}
break;
}
const blocks: EmbedBlock[] = [
{
type: 'header',
text: {
type: 'plain_text',
text: header,
},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*${payload.subject}*`,
},
},
];
if (payload.message) {
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: payload.message,
},
accessory: payload.image
? {
type: 'image',
image_url: payload.image,
alt_text: payload.subject,
}
: undefined,
});
}
if (fields.length > 0) {
blocks.push({
type: 'section',
fields: [
...fields,
...(payload.extra ?? []).map(
(extra): EmbedField => ({
type: 'mrkdwn',
text: `*${extra.name}*\n${extra.value}`,
})
),
],
});
}
if (actionUrl) {
blocks.push({
type: 'actions',
elements: [
{
action_id: 'button-action',
type: 'button',
url: actionUrl,
value: 'open_overseerr',
text: {
type: 'plain_text',
text: 'Open Overseerr',
},
},
],
});
}
return {
blocks,
};
}
// TODO: Add checking for type here once we add notification type filters for agents
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public shouldSend(_type: Notification): boolean {
if (this.getSettings().enabled && this.getSettings().options.webhookUrl) {
return true;
}
return false;
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending slack notification', { label: 'Notifications' });
try {
const webhookUrl = this.getSettings().options.webhookUrl;
if (!webhookUrl) {
return false;
}
await axios.post(webhookUrl, this.buildEmbed(type, payload));
return true;
} catch (e) {
logger.error('Error sending Slack notification', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
}
export default SlackAgent;

@ -5,7 +5,8 @@ export enum Notification {
MEDIA_PENDING = 2,
MEDIA_APPROVED = 4,
MEDIA_AVAILABLE = 8,
TEST_NOTIFICATION = 16,
MEDIA_FAILED = 16,
TEST_NOTIFICATION = 32,
}
class NotificationManager {

@ -7,6 +7,8 @@ export enum Permission {
REQUEST = 32,
VOTE = 64,
AUTO_APPROVE = 128,
AUTO_APPROVE_MOVIE = 256,
AUTO_APPROVE_TV = 512,
}
/**

@ -66,6 +66,12 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig {
};
}
export interface NotificationAgentSlack extends NotificationAgentConfig {
options: {
webhookUrl: string;
};
}
export interface NotificationAgentEmail extends NotificationAgentConfig {
options: {
emailFrom: string;
@ -81,6 +87,7 @@ export interface NotificationAgentEmail extends NotificationAgentConfig {
interface NotificationAgents {
email: NotificationAgentEmail;
discord: NotificationAgentDiscord;
slack: NotificationAgentSlack;
}
interface NotificationSettings {
@ -142,6 +149,13 @@ class Settings {
webhookUrl: '',
},
},
slack: {
enabled: false,
types: 0,
options: {
webhookUrl: '',
},
},
},
},
};

@ -1,5 +1,17 @@
import * as winston from 'winston';
import 'winston-daily-rotate-file';
import path from 'path';
import fs from 'fs';
// Migrate away from old log
const OLD_LOG_FILE = path.join(__dirname, '../config/logs/overseerr.log');
if (fs.existsSync(OLD_LOG_FILE)) {
const file = fs.lstatSync(OLD_LOG_FILE);
if (!file.isSymbolicLink()) {
fs.unlinkSync(OLD_LOG_FILE);
}
}
const hformat = winston.format.printf(
({ level, label, message, timestamp, ...metadata }) => {
@ -29,10 +41,14 @@ const logger = winston.createLogger({
hformat
),
}),
new winston.transports.File({
filename: path.join(__dirname, '../config/logs/overseerr.log'),
maxsize: 20971520,
maxFiles: 6,
new winston.transports.DailyRotateFile({
filename: path.join(__dirname, '../config/logs/overseerr-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
zippedArchive: true,
maxSize: '20m',
maxFiles: '7d',
createSymlink: true,
symlinkName: 'overseerr.log',
}),
],
});

@ -0,0 +1,29 @@
import { TmdbCollection } from '../api/themoviedb';
import Media from '../entity/Media';
import { mapMovieResult, MovieResult } from './Search';
export interface Collection {
id: number;
name: string;
overview?: string;
posterPath?: string;
backdropPath?: string;
parts: MovieResult[];
}
export const mapCollection = (
collection: TmdbCollection,
media: Media[]
): Collection => ({
id: collection.id,
name: collection.name,
overview: collection.overview,
posterPath: collection.poster_path,
backdropPath: collection.backdrop_path,
parts: collection.parts.map((part) =>
mapMovieResult(
part,
media?.find((req) => req.tmdbId === part.id)
)
),
});

@ -8,9 +8,26 @@ import {
mapCrew,
ExternalIds,
mapExternalIds,
mapVideos,
} from './common';
import Media from '../entity/Media';
export interface Video {
url?: string;
site: 'YouTube';
key: string;
name: string;
size: number;
type:
| 'Clip'
| 'Teaser'
| 'Trailer'
| 'Featurette'
| 'Opening Credits'
| 'Behind the Scenes'
| 'Bloopers';
}
export interface MovieDetails {
id: number;
imdbId?: string;
@ -23,6 +40,7 @@ export interface MovieDetails {
originalTitle: string;
overview?: string;
popularity: number;
relatedVideos?: Video[];
posterPath?: string;
productionCompanies: ProductionCompany[];
productionCountries: {
@ -46,6 +64,12 @@ export interface MovieDetails {
cast: Cast[];
crew: Crew[];
};
collection?: {
id: number;
name: string;
posterPath?: string;
backdropPath?: string;
};
mediaInfo?: Media;
externalIds: ExternalIds;
}
@ -58,6 +82,7 @@ export const mapMovieDetails = (
adult: movie.adult,
budget: movie.budget,
genres: movie.genres,
relatedVideos: mapVideos(movie.videos),
originalLanguage: movie.original_language,
originalTitle: movie.original_title,
popularity: movie.popularity,
@ -87,6 +112,14 @@ export const mapMovieDetails = (
cast: movie.credits.cast.map(mapCast),
crew: movie.credits.crew.map(mapCrew),
},
collection: movie.belongs_to_collection
? {
id: movie.belongs_to_collection.id,
name: movie.belongs_to_collection.name,
posterPath: movie.belongs_to_collection.poster_path,
backdropPath: movie.belongs_to_collection.backdrop_path,
}
: undefined,
externalIds: mapExternalIds(movie.external_ids),
mediaInfo: media,
});

@ -8,6 +8,7 @@ import {
ExternalIds,
mapExternalIds,
Keyword,
mapVideos,
} from './common';
import {
TmdbTvEpisodeResult,
@ -16,6 +17,7 @@ import {
TmdbSeasonWithEpisodes,
} from '../api/themoviedb';
import type Media from '../entity/Media';
import { Video } from './Movie';
interface Episode {
id: number;
@ -67,6 +69,7 @@ export interface TvDetails {
genres: Genre[];
homepage: string;
inProduction: boolean;
relatedVideos?: Video[];
languages: string[];
lastAirDate: string;
lastEpisodeToAir?: Episode;
@ -145,6 +148,7 @@ export const mapTvDetails = (
id: genre.id,
name: genre.name,
})),
relatedVideos: mapVideos(show.videos),
homepage: show.homepage,
id: show.id,
inProduction: show.in_production,

@ -2,8 +2,12 @@ import {
TmdbCreditCast,
TmdbCreditCrew,
TmdbExternalIds,
TmdbVideo,
TmdbVideoResult,
} from '../api/themoviedb';
import { Video } from '../models/Movie';
export interface ProductionCompany {
id: number;
logoPath?: string;
@ -84,3 +88,18 @@ export const mapExternalIds = (eids: TmdbExternalIds): ExternalIds => ({
tvrageId: eids.tvrage_id,
twitterId: eids.twitter_id,
});
export const mapVideos = (videoResult: TmdbVideoResult): Video[] =>
videoResult?.results.map(({ key, name, size, type, site }: TmdbVideo) => ({
site,
key,
name,
size,
type,
url: siteUrlCreator(site, key),
}));
const siteUrlCreator = (site: Video['site'], key: string): string =>
({
YouTube: `https://www.youtube.com/watch?v=${key}/`,
}[site]);

@ -0,0 +1,27 @@
import { Router } from 'express';
import TheMovieDb from '../api/themoviedb';
import Media from '../entity/Media';
import { mapCollection } from '../models/Collection';
const collectionRoutes = Router();
collectionRoutes.get<{ id: string }>('/:id', async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const collection = await tmdb.getCollection({
collectionId: Number(req.params.id),
language: req.query.language as string,
});
const media = await Media.getRelatedMedia(
collection.parts.map((part) => part.id)
);
return res.status(200).json(mapCollection(collection, media));
} catch (e) {
return next({ status: 404, message: 'Collection does not exist' });
}
});
export default collectionRoutes;

@ -12,6 +12,7 @@ import movieRoutes from './movie';
import tvRoutes from './tv';
import mediaRoutes from './media';
import personRoutes from './person';
import collectionRoutes from './collection';
const router = Router();
@ -34,6 +35,7 @@ router.use('/movie', isAuthenticated(), movieRoutes);
router.use('/tv', isAuthenticated(), tvRoutes);
router.use('/media', isAuthenticated(), mediaRoutes);
router.use('/person', isAuthenticated(), personRoutes);
router.use('/collection', isAuthenticated(), collectionRoutes);
router.use('/auth', authRoutes);
router.get('/', (_req, res) => {

@ -4,6 +4,7 @@ import { mapMovieDetails } from '../models/Movie';
import { mapMovieResult } from '../models/Search';
import Media from '../entity/Media';
import RottenTomatoes from '../api/rottentomatoes';
import logger from '../logger';
const movieRoutes = Router();
@ -11,15 +12,19 @@ movieRoutes.get('/:id', async (req, res, next) => {
const tmdb = new TheMovieDb();
try {
const movie = await tmdb.getMovie({
const tmdbMovie = await tmdb.getMovie({
movieId: Number(req.params.id),
language: req.query.language as string,
});
const media = await Media.getMedia(movie.id);
const media = await Media.getMedia(tmdbMovie.id);
return res.status(200).json(mapMovieDetails(movie, media));
return res.status(200).json(mapMovieDetails(tmdbMovie, media));
} catch (e) {
logger.error('Something went wrong getting movie', {
label: 'Movie',
message: e.message,
});
return next({ status: 404, message: 'Movie does not exist' });
}
});

@ -127,12 +127,16 @@ requestRoutes.post(
media,
requestedBy: req.user,
// If the user is an admin or has the "auto approve" permission, automatically approve the request
status: req.user?.hasPermission(Permission.AUTO_APPROVE)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy: req.user?.hasPermission(Permission.AUTO_APPROVE)
? req.user
: undefined,
status:
req.user?.hasPermission(Permission.AUTO_APPROVE) ||
req.user?.hasPermission(Permission.AUTO_APPROVE_MOVIE)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy:
req.user?.hasPermission(Permission.AUTO_APPROVE) ||
req.user?.hasPermission(Permission.AUTO_APPROVE_MOVIE)
? req.user
: undefined,
});
await requestRepository.save(request);
@ -172,19 +176,25 @@ requestRoutes.post(
} as Media,
requestedBy: req.user,
// If the user is an admin or has the "auto approve" permission, automatically approve the request
status: req.user?.hasPermission(Permission.AUTO_APPROVE)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy: req.user?.hasPermission(Permission.AUTO_APPROVE)
? req.user
: undefined,
status:
req.user?.hasPermission(Permission.AUTO_APPROVE) ||
req.user?.hasPermission(Permission.AUTO_APPROVE_TV)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
modifiedBy:
req.user?.hasPermission(Permission.AUTO_APPROVE) ||
req.user?.hasPermission(Permission.AUTO_APPROVE_TV)
? req.user
: undefined,
seasons: finalSeasons.map(
(sn) =>
new SeasonRequest({
seasonNumber: sn,
status: req.user?.hasPermission(Permission.AUTO_APPROVE)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
status:
req.user?.hasPermission(Permission.AUTO_APPROVE) ||
req.user?.hasPermission(Permission.AUTO_APPROVE_TV)
? MediaRequestStatus.APPROVED
: MediaRequestStatus.PENDING,
})
),
});
@ -244,6 +254,32 @@ requestRoutes.delete('/:requestId', async (req, res, next) => {
}
});
requestRoutes.post<{
requestId: string;
}>(
'/:requestId/retry',
isAuthenticated(Permission.MANAGE_REQUESTS),
async (req, res, next) => {
const requestRepository = getRepository(MediaRequest);
try {
const request = await requestRepository.findOneOrFail({
where: { id: Number(req.params.requestId) },
relations: ['requestedBy', 'modifiedBy'],
});
await request.updateParentStatus();
await request.sendMedia();
return res.status(200).json(request);
} catch (e) {
logger.error('Error processing request retry', {
label: 'Media Request',
message: e.message,
});
next({ status: 404, message: 'Request not found' });
}
}
);
requestRoutes.get<{
requestId: string;
status: 'pending' | 'approve' | 'decline';

@ -24,6 +24,7 @@ import { SettingsAboutResponse } from '../interfaces/api/settingsInterfaces';
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';
const settingsRoutes = Router();
@ -468,6 +469,40 @@ settingsRoutes.post('/notifications/discord/test', (req, res, next) => {
return res.status(204).send();
});
settingsRoutes.get('/notifications/slack', (_req, res) => {
const settings = getSettings();
res.status(200).json(settings.notifications.agents.slack);
});
settingsRoutes.post('/notifications/slack', (req, res) => {
const settings = getSettings();
settings.notifications.agents.slack = req.body;
settings.save();
res.status(200).json(settings.notifications.agents.slack);
});
settingsRoutes.post('/notifications/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();
});
settingsRoutes.get('/notifications/email', (_req, res) => {
const settings = getSettings();

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

After

Width:  |  Height:  |  Size: 1.3 KiB

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

After

Width:  |  Height:  |  Size: 1.2 KiB

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

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

@ -0,0 +1,267 @@
import axios from 'axios';
import Head from 'next/head';
import { useRouter } from 'next/router';
import React, { useContext, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import { MediaStatus } from '../../../server/constants/media';
import type { MediaRequest } from '../../../server/entity/MediaRequest';
import type { Collection } from '../../../server/models/Collection';
import { LanguageContext } from '../../context/LanguageContext';
import globalMessages from '../../i18n/globalMessages';
import Error from '../../pages/_error';
import Badge from '../Common/Badge';
import Button from '../Common/Button';
import LoadingSpinner from '../Common/LoadingSpinner';
import Modal from '../Common/Modal';
import Slider from '../Slider';
import TitleCard from '../TitleCard';
import Transition from '../Transition';
const messages = defineMessages({
overviewunavailable: 'Overview unavailable',
overview: 'Overview',
movies: 'Movies',
numberofmovies: 'Number of Movies: {count}',
requesting: 'Requesting…',
request: 'Request',
requestcollection: 'Request Collection',
requestswillbecreated:
'The following titles will have requests created for them:',
requestSuccess: '<strong>{title}</strong> successfully requested!',
});
interface CollectionDetailsProps {
collection?: Collection;
}
const CollectionDetails: React.FC<CollectionDetailsProps> = ({
collection,
}) => {
const intl = useIntl();
const router = useRouter();
const { addToast } = useToasts();
const { locale } = useContext(LanguageContext);
const [requestModal, setRequestModal] = useState(false);
const [isRequesting, setRequesting] = useState(false);
const { data, error, revalidate } = useSWR<Collection>(
`/api/v1/collection/${router.query.collectionId}?language=${locale}`,
{
initialData: collection,
revalidateOnMount: true,
}
);
if (!data && !error) {
return <LoadingSpinner />;
}
if (!data) {
return <Error statusCode={404} />;
}
const requestableParts = data.parts.filter(
(part) => !part.mediaInfo || part.mediaInfo.status === MediaStatus.UNKNOWN
);
const requestBundle = async () => {
try {
setRequesting(true);
await Promise.all(
requestableParts.map(async (part) => {
await axios.post<MediaRequest>('/api/v1/request', {
mediaId: part.id,
mediaType: 'movie',
});
})
);
addToast(
<span>
{intl.formatMessage(messages.requestSuccess, {
title: data?.name,
strong: function strong(msg) {
return <strong>{msg}</strong>;
},
})}
</span>,
{ appearance: 'success', autoDismiss: true }
);
} catch (e) {
addToast('Something went wrong requesting the collection.', {
appearance: 'error',
autoDismiss: true,
});
} finally {
setRequesting(false);
setRequestModal(false);
revalidate();
}
};
return (
<div
className="px-4 pt-4 -mx-4 -mt-2 bg-center bg-cover sm:px-8 "
style={{
height: 493,
backgroundImage: `linear-gradient(180deg, rgba(17, 24, 39, 0.47) 0%, rgba(17, 24, 39, 1) 100%), url(//image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data.backdropPath})`,
}}
>
<Head>
<title>{data.name} - Overseerr</title>
</Head>
<Transition
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="opacity-100 transition duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={requestModal}
>
<Modal
onOk={() => requestBundle()}
okText={
isRequesting
? intl.formatMessage(messages.requesting)
: intl.formatMessage(messages.request)
}
okDisabled={isRequesting}
okButtonType="primary"
onCancel={() => setRequestModal(false)}
title={intl.formatMessage(messages.requestcollection)}
iconSvg={
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
}
>
<p>{intl.formatMessage(messages.requestswillbecreated)}</p>
<ul className="py-4 pl-8 list-disc">
{data.parts
.filter(
(part) =>
!part.mediaInfo ||
part.mediaInfo?.status === MediaStatus.UNKNOWN
)
.map((part) => (
<li key={`request-part-${part.id}`}>{part.title}</li>
))}
</ul>
</Modal>
</Transition>
<div className="flex flex-col items-center pt-4 md:flex-row md:items-end">
<div className="flex-shrink-0 md:mr-4">
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
alt=""
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-52"
/>
</div>
<div className="flex flex-col mt-4 text-center text-white md:mr-4 md:mt-0 md:text-left">
<div className="mb-2">
{data.parts.every(
(part) => part.mediaInfo?.status === MediaStatus.AVAILABLE
) && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.available)}
</Badge>
)}
{!data.parts.every(
(part) => part.mediaInfo?.status === MediaStatus.AVAILABLE
) &&
data.parts.some(
(part) => part.mediaInfo?.status === MediaStatus.AVAILABLE
) && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.partiallyavailable)}
</Badge>
)}
</div>
<h1 className="text-2xl md:text-4xl">{data.name}</h1>
<span className="mt-1 text-xs md:text-base md:mt-0">
{intl.formatMessage(messages.numberofmovies, {
count: data.parts.length,
})}
</span>
</div>
<div className="flex justify-end flex-1 mt-4 md:mt-0">
{data.parts.some(
(part) =>
!part.mediaInfo || part.mediaInfo?.status === MediaStatus.UNKNOWN
) && (
<Button buttonType="primary" onClick={() => setRequestModal(true)}>
<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>
{intl.formatMessage(messages.requestcollection)}
</Button>
)}
</div>
</div>
<div className="flex flex-col pt-8 pb-4 text-white md:flex-row">
<div className="flex-1 md:mr-8">
<h2 className="text-xl md:text-2xl">
{intl.formatMessage(messages.overview)}
</h2>
<p className="pt-2 text-sm md:text-base">
{data.overview
? data.overview
: intl.formatMessage(messages.overviewunavailable)}
</p>
</div>
</div>
<div className="mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center text-xl leading-7 text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>{intl.formatMessage(messages.movies)}</span>
</div>
</div>
</div>
<Slider
sliderKey="collection-movies"
isLoading={false}
isEmpty={data.parts.length === 0}
items={data.parts.map((title) => (
<TitleCard
key={`collection-movie-${title.id}`}
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}
/>
))}
/>
<div className="pb-8" />
</div>
);
};
export default CollectionDetails;

@ -2,31 +2,66 @@ import React from 'react';
interface AlertProps {
title: string;
type?: 'warning';
type?: 'warning' | 'info';
}
const Alert: React.FC<AlertProps> = ({ title, children }) => {
return (
<div className="rounded-md bg-yellow-600 p-4 mb-8">
<div className="flex">
<div className="flex-shrink-0">
const Alert: React.FC<AlertProps> = ({ title, children, type }) => {
let design = {
bgColor: 'bg-yellow-600',
titleColor: 'text-yellow-200',
textColor: 'text-yellow-300',
svg: (
<svg
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
),
};
switch (type) {
case 'info':
design = {
bgColor: 'bg-indigo-600',
titleColor: 'text-indigo-200',
textColor: 'text-indigo-300',
svg: (
<svg
className="h-5 w-5 text-yellow-200"
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
),
};
break;
}
return (
<div className={`rounded-md p-4 mb-8 ${design.bgColor}`}>
<div className="flex">
<div className={`flex-shrink-0 ${design.titleColor}`}>{design.svg}</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-200">{title}</h3>
<div className="mt-2 text-sm text-yellow-300">{children}</div>
<h3 className={`text-sm font-medium ${design.titleColor}`}>
{title}
</h3>
<div className={`mt-2 text-sm ${design.textColor}`}>{children}</div>
</div>
</div>
</div>

@ -51,7 +51,7 @@ const Button: React.FC<ButtonProps> = ({
break;
default:
buttonStyle.push(
'leading-5 font-medium rounded-md text-gray-200 bg-gray-500 hover:bg-gray-400 hover:text-white focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-400 disabled:opacity-50'
'leading-5 font-medium rounded-md text-gray-200 bg-gray-500 hover:bg-gray-400 group-hover:bg-gray-400 hover:text-white group-hover:text-white focus:border-blue-300 focus:ring-blue active:text-gray-200 active:bg-gray-400 disabled:opacity-50'
);
}

@ -14,7 +14,7 @@ const DropdownItem: React.FC<AnchorHTMLAttributes<HTMLAnchorElement>> = ({
...props
}) => (
<a
className="cursor-pointer flex items-center px-4 py-2 text-sm leading-5 text-white bg-indigo-600 hover:bg-indigo-500 hover:text-white focus:outline-none focus:border-indigo-700 focus:text-white"
className="flex items-center px-4 py-2 text-sm leading-5 text-white bg-indigo-600 cursor-pointer hover:bg-indigo-500 hover:text-white focus:outline-none focus:border-indigo-700 focus:text-white"
{...props}
>
{children}
@ -31,6 +31,7 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
text,
children,
dropdownIcon,
className,
...props
}) => {
const [isOpen, setIsOpen] = useState(false);
@ -38,22 +39,22 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
useClickOutside(buttonRef, () => setIsOpen(false));
return (
<span className="relative z-0 inline-flex shadow-sm rounded-md">
<span className="relative z-0 inline-flex rounded-md shadow-sm">
<button
type="button"
className={`relative inline-flex items-center px-4 py-2 text-white bg-indigo-600 hover:bg-indigo-500 text-sm leading-5 font-medium hover:text-white focus:ring-indigo active:bg-indigo-700 focus:z-10 focus:outline-none focus:ring-blue transition ease-in-out duration-150 ${
children ? 'rounded-l-md' : 'rounded-md'
}`}
} ${className}`}
ref={buttonRef}
{...props}
>
{text}
</button>
<span className="-ml-px relative block">
<span className="relative block -ml-px">
{children && (
<button
type="button"
className="relative inline-flex items-center px-2 py-2 rounded-r-md bg-indigo-700 hover:bg-indigo-500 text-sm leading-5 font-medium text-white focus:z-10 focus:outline-none active:bg-indigo-700 border border-indigo-600 focus:ring-blue transition ease-in-out duration-150"
className="relative inline-flex items-center px-2 py-2 text-sm font-medium leading-5 text-white transition duration-150 ease-in-out bg-indigo-700 border border-indigo-600 rounded-r-md hover:bg-indigo-500 focus:z-10 focus:outline-none active:bg-indigo-700 focus:ring-blue"
aria-label="Expand"
onClick={() => setIsOpen((state) => !state)}
>
@ -61,7 +62,7 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
dropdownIcon
) : (
<svg
className="h-5 w-5"
className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
@ -84,8 +85,8 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<div className="origin-top-right absolute right-0 mt-2 -mr-1 w-56 rounded-md shadow-lg">
<div className="rounded-md bg-indigo-600 ring-1 ring-black ring-opacity-5">
<div className="absolute right-0 w-56 mt-2 -mr-1 origin-top-right rounded-md shadow-lg">
<div className="bg-indigo-600 rounded-md ring-1 ring-black ring-opacity-5">
<div className="py-1">{children}</div>
</div>
</div>

@ -23,7 +23,6 @@ import {
MediaRequestStatus,
} from '../../../server/constants/media';
import RequestModal from '../RequestModal';
import Badge from '../Common/Badge';
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
import axios from 'axios';
import SlideOver from '../Common/SlideOver';
@ -36,9 +35,9 @@ import RTAudRotten from '../../assets/rt_aud_rotten.svg';
import type { RTRating } from '../../../server/api/rottentomatoes';
import Error from '../../pages/_error';
import Head from 'next/head';
import globalMessages from '../../i18n/globalMessages';
import ExternalLinkBlock from '../ExternalLinkBlock';
import { sortCrewPriority } from '../../utils/creditHelpers';
import StatusBadge from '../StatusBadge';
const messages = defineMessages({
releasedate: 'Release Date',
@ -46,6 +45,7 @@ const messages = defineMessages({
status: 'Status',
revenue: 'Revenue',
budget: 'Budget',
watchtrailer: 'Watch Trailer',
originallanguage: 'Original Language',
overview: 'Overview',
runtime: '{minutes} minutes',
@ -69,6 +69,7 @@ const messages = defineMessages({
decline: 'Decline',
studio: 'Studio',
viewfullcrew: 'View Full Crew',
view: 'View',
});
interface MovieDetailsProps {
@ -121,6 +122,11 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
(request) => request.status === MediaRequestStatus.PENDING
);
const trailerUrl = data.relatedVideos
?.filter((r) => r.type === 'Trailer')
.sort((a, b) => a.size - b.size)
.pop()?.url;
const modifyRequest = async (type: 'approve' | 'decline') => {
const response = await axios.get(
`/api/v1/request/${activeRequest?.id}/${type}`
@ -200,37 +206,23 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</div>
)}
</SlideOver>
<div className="flex flex-col items-center pt-4 md:flex-row md:items-end">
<div className="flex-shrink-0 md:mr-4">
<div className="flex flex-col items-center pt-4 lg:flex-row lg:items-end">
<div className="lg:mr-4">
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
alt=""
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-52"
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-44 lg:w-52"
/>
</div>
<div className="flex flex-col mt-4 text-center text-white md:mr-4 md:mt-0 md:text-left">
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
<div className="mb-2">
{data.mediaInfo?.status === MediaStatus.AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.available)}
</Badge>
)}
{data.mediaInfo?.status === MediaStatus.PROCESSING && (
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.unavailable)}
</Badge>
)}
{data.mediaInfo?.status === MediaStatus.PENDING && (
<Badge badgeType="warning">
{intl.formatMessage(globalMessages.pending)}
</Badge>
)}
<StatusBadge status={data.mediaInfo?.status} />
</div>
<h1 className="text-2xl md:text-4xl">
<h1 className="text-2xl lg:text-4xl">
{data.title}{' '}
<span className="text-2xl">({data.releaseDate.slice(0, 4)})</span>
</h1>
<span className="mt-1 text-xs md:text-base md:mt-0">
<span className="mt-1 text-xs lg:text-base lg:mt-0">
{(data.runtime ?? 0) > 0 && (
<>
<FormattedMessage
@ -243,16 +235,44 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
{data.genres.map((g) => g.name).join(', ')}
</span>
</div>
<div className="flex justify-end flex-1 mt-4 md:mt-0">
<div className="flex justify-end flex-shrink-0 mt-4 lg:mt-0">
{trailerUrl && (
<a href={trailerUrl} target={'_blank'} rel="noreferrer">
<Button buttonType="ghost">
<svg
className="w-5 h-5 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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<FormattedMessage {...messages.watchtrailer} />
</Button>
</a>
)}
{(!data.mediaInfo ||
data.mediaInfo?.status === MediaStatus.UNKNOWN) && (
<Button
buttonType="primary"
className="ml-2"
onClick={() => setShowRequestModal(true)}
>
{activeRequest ? (
<svg
className="w-4 mr-1"
className="w-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
@ -265,7 +285,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</svg>
) : (
<svg
className="w-4 mr-1"
className="w-5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -316,6 +336,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</>
}
onClick={() => setShowRequestModal(true)}
className="ml-2"
>
{hasPermission(Permission.MANAGE_REQUESTS) && (
<>
@ -438,6 +459,27 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
)}
</div>
<div className="w-full mt-8 md:w-80 md:mt-0">
{data.collection && (
<div className="mb-6">
<Link href={`/collection/${data.collection.id}`}>
<a>
<div
className="relative transition duration-300 transform scale-100 bg-gray-800 bg-center bg-cover rounded-lg shadow-md cursor-pointer group hover:scale-105"
style={{
backgroundImage: `linear-gradient(180deg, rgba(31, 41, 55, 0.47) 0%, rgba(31, 41, 55, 0.80) 100%), url(//image.tmdb.org/t/p/w1440_and_h320_multi_faces/${data.collection.backdropPath})`,
}}
>
<div className="flex items-center justify-between p-4 text-gray-200 transition duration-300 h-14 group-hover:text-white">
<div>{data.collection.name}</div>
<Button buttonSize="sm">
{intl.formatMessage(messages.view)}
</Button>
</div>
</div>
</a>
</Link>
</div>
)}
<div className="bg-gray-900 border border-gray-800 rounded-lg shadow">
{(data.voteCount > 0 || ratingData) && (
<div className="flex items-center justify-center px-4 py-2 border-b border-gray-800 last:border-b-0">

@ -0,0 +1,97 @@
import React from 'react';
import { hasPermission } from '../../../server/lib/permissions';
import { Permission, User } from '../../hooks/useUser';
export interface PermissionItem {
id: string;
name: string;
description: string;
permission: Permission;
children?: PermissionItem[];
}
interface PermissionOptionProps {
option: PermissionItem;
currentPermission: number;
user?: User;
parent?: PermissionItem;
onUpdate: (newPermissions: number) => void;
}
const PermissionOption: React.FC<PermissionOptionProps> = ({
option,
currentPermission,
onUpdate,
user,
parent,
}) => {
return (
<>
<div
className={`relative flex items-start first:mt-0 mt-4 ${
(option.permission !== Permission.ADMIN &&
hasPermission(Permission.ADMIN, currentPermission)) ||
(!!parent?.permission &&
hasPermission(parent.permission, currentPermission)) ||
(user && user.id !== 1 && option.permission === Permission.ADMIN) ||
(user &&
!hasPermission(Permission.MANAGE_SETTINGS, user.permissions) &&
option.permission === Permission.MANAGE_SETTINGS)
? 'opacity-50'
: ''
}`}
>
<div className="flex items-center h-5">
<input
id={option.id}
name="permissions"
type="checkbox"
className="w-4 h-4 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
disabled={
(option.permission !== Permission.ADMIN &&
hasPermission(Permission.ADMIN, currentPermission)) ||
(!!parent?.permission &&
hasPermission(parent.permission, currentPermission)) ||
(user &&
user.id !== 1 &&
option.permission === Permission.ADMIN) ||
(user &&
!hasPermission(Permission.MANAGE_SETTINGS, user.permissions) &&
option.permission === Permission.MANAGE_SETTINGS)
}
onClick={() => {
onUpdate(
hasPermission(option.permission, currentPermission)
? currentPermission - option.permission
: currentPermission + option.permission
);
}}
checked={
hasPermission(option.permission, currentPermission) ||
(!!parent?.permission &&
hasPermission(parent.permission, currentPermission))
}
/>
</div>
<div className="ml-3 text-sm leading-5">
<label htmlFor={option.id} className="font-medium">
{option.name}
</label>
<p className="text-gray-500">{option.description}</p>
</div>
</div>
{(option.children ?? []).map((child) => (
<div key={`permission-child-${child.id}`} className="pl-6 mt-4">
<PermissionOption
option={child}
currentPermission={currentPermission}
onUpdate={(newPermission) => onUpdate(newPermission)}
parent={option}
/>
</div>
))}
</>
);
};
export default PermissionOption;

@ -44,30 +44,18 @@ const PersonDetails: React.FC = () => {
}
const sortedCast = combinedCredits?.cast.sort((a, b) => {
const aDate =
a.mediaType === 'movie'
? a.releaseDate?.slice(0, 4) ?? 0
: a.firstAirDate?.slice(0, 4) ?? 0;
const bDate =
b.mediaType === 'movie'
? b.releaseDate?.slice(0, 4) ?? 0
: b.firstAirDate?.slice(0, 4) ?? 0;
if (aDate > bDate) {
const aVotes = a.voteCount ?? 0;
const bVotes = b.voteCount ?? 0;
if (aVotes > bVotes) {
return -1;
}
return 1;
});
const sortedCrew = combinedCredits?.crew.sort((a, b) => {
const aDate =
a.mediaType === 'movie'
? a.releaseDate?.slice(0, 4) ?? 0
: a.firstAirDate?.slice(0, 4) ?? 0;
const bDate =
b.mediaType === 'movie'
? b.releaseDate?.slice(0, 4) ?? 0
: b.firstAirDate?.slice(0, 4) ?? 0;
if (aDate > bDate) {
const aVotes = a.voteCount ?? 0;
const bVotes = b.voteCount ?? 0;
if (aVotes > bVotes) {
return -1;
}
return 1;
@ -75,6 +63,94 @@ const PersonDetails: React.FC = () => {
const isLoading = !combinedCredits && !errorCombinedCredits;
const cast = (sortedCast ?? []).length > 0 && (
<>
<div className="relative z-10 mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>{intl.formatMessage(messages.appearsin)}</span>
</div>
</div>
</div>
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
{sortedCast?.map((media, index) => {
return (
<li
key={`list-cast-item-${media.id}-${index}`}
className="flex flex-col items-center col-span-1 text-center"
>
<TitleCard
id={media.id}
title={media.mediaType === 'movie' ? media.title : media.name}
userScore={media.voteAverage}
year={
media.mediaType === 'movie'
? media.releaseDate
: media.firstAirDate
}
image={media.posterPath}
summary={media.overview}
mediaType={media.mediaType as 'movie' | 'tv'}
status={media.mediaInfo?.status}
canExpand
/>
{media.character && (
<div className="mt-2 text-xs text-center text-gray-300 truncate w-36 sm:w-36 md:w-44">
{intl.formatMessage(messages.ascharacter, {
character: media.character,
})}
</div>
)}
</li>
);
})}
</ul>
</>
);
const crew = (sortedCrew ?? []).length > 0 && (
<>
<div className="relative z-10 mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>{intl.formatMessage(messages.crewmember)}</span>
</div>
</div>
</div>
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
{sortedCrew?.map((media, index) => {
return (
<li
key={`list-crew-item-${media.id}-${index}`}
className="flex flex-col items-center col-span-1 text-center"
>
<TitleCard
id={media.id}
title={media.mediaType === 'movie' ? media.title : media.name}
userScore={media.voteAverage}
year={
media.mediaType === 'movie'
? media.releaseDate
: media.firstAirDate
}
image={media.posterPath}
summary={media.overview}
mediaType={media.mediaType as 'movie' | 'tv'}
status={media.mediaInfo?.status}
canExpand
/>
{media.job && (
<div className="mt-2 text-xs text-center text-gray-300 truncate w-36 sm:w-36 md:w-44">
{media.job}
</div>
)}
</li>
);
})}
</ul>
</>
);
return (
<>
{(sortedCrew || sortedCast) && (
@ -126,96 +202,7 @@ const PersonDetails: React.FC = () => {
</div>
</div>
</div>
{(sortedCast ?? []).length > 0 && (
<>
<div className="relative z-10 mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>{intl.formatMessage(messages.appearsin)}</span>
</div>
</div>
</div>
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
{sortedCast?.map((media, index) => {
return (
<li
key={`list-cast-item-${media.id}-${index}`}
className="flex flex-col items-center col-span-1 text-center"
>
<TitleCard
id={media.id}
title={
media.mediaType === 'movie' ? media.title : media.name
}
userScore={media.voteAverage}
year={
media.mediaType === 'movie'
? media.releaseDate
: media.firstAirDate
}
image={media.posterPath}
summary={media.overview}
mediaType={media.mediaType as 'movie' | 'tv'}
status={media.mediaInfo?.status}
canExpand
/>
{media.character && (
<div className="mt-2 text-xs text-center text-gray-300 truncate w-36 sm:w-36 md:w-44">
{intl.formatMessage(messages.ascharacter, {
character: media.character,
})}
</div>
)}
</li>
);
})}
</ul>
</>
)}
{(sortedCrew ?? []).length > 0 && (
<>
<div className="relative z-10 mt-6 mb-4 md:flex md:items-center md:justify-between">
<div className="flex-1 min-w-0">
<div className="inline-flex items-center text-xl leading-7 text-gray-300 hover:text-white sm:text-2xl sm:leading-9 sm:truncate">
<span>{intl.formatMessage(messages.crewmember)}</span>
</div>
</div>
</div>
<ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-7 2xl:grid-cols-8">
{sortedCrew?.map((media, index) => {
return (
<li
key={`list-crew-item-${media.id}-${index}`}
className="flex flex-col items-center col-span-1 text-center"
>
<TitleCard
id={media.id}
title={
media.mediaType === 'movie' ? media.title : media.name
}
userScore={media.voteAverage}
year={
media.mediaType === 'movie'
? media.releaseDate
: media.firstAirDate
}
image={media.posterPath}
summary={media.overview}
mediaType={media.mediaType as 'movie' | 'tv'}
status={media.mediaInfo?.status}
canExpand
/>
{media.job && (
<div className="mt-2 text-xs text-center text-gray-300 truncate w-36 sm:w-36 md:w-44">
{media.job}
</div>
)}
</li>
);
})}
</ul>
</>
)}
{data.knownForDepartment === 'Acting' ? [cast, crew] : [crew, cast]}
{isLoading && <LoadingSpinner />}
</>
);

@ -1,4 +1,4 @@
import React, { useContext } from 'react';
import React, { useContext, useState } from 'react';
import { useInView } from 'react-intersection-observer';
import type { MediaRequest } from '../../../../server/entity/MediaRequest';
import {
@ -15,16 +15,21 @@ import useSWR from 'swr';
import Badge from '../../Common/Badge';
import StatusBadge from '../../StatusBadge';
import Table from '../../Common/Table';
import { MediaRequestStatus } from '../../../../server/constants/media';
import {
MediaRequestStatus,
MediaStatus,
} from '../../../../server/constants/media';
import Button from '../../Common/Button';
import axios from 'axios';
import globalMessages from '../../../i18n/globalMessages';
import Link from 'next/link';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages({
requestedby: 'Requested by {username}',
seasons: 'Seasons',
notavailable: 'N/A',
failedretry: 'Something went wrong retrying the request',
});
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
@ -33,13 +38,17 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
interface RequestItemProps {
request: MediaRequest;
onDelete: () => void;
revalidateList: () => void;
}
const RequestItem: React.FC<RequestItemProps> = ({ request, onDelete }) => {
const RequestItem: React.FC<RequestItemProps> = ({
request,
revalidateList,
}) => {
const { ref, inView } = useInView({
triggerOnce: true,
});
const { addToast } = useToasts();
const intl = useIntl();
const { hasPermission } = useUser();
const { locale } = useContext(LanguageContext);
@ -50,13 +59,15 @@ const RequestItem: React.FC<RequestItemProps> = ({ request, onDelete }) => {
const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? `${url}?language=${locale}` : null
);
const { data: requestData, revalidate } = useSWR<MediaRequest>(
const { data: requestData, revalidate, mutate } = useSWR<MediaRequest>(
`/api/v1/request/${request.id}`,
{
initialData: request,
}
);
const [isRetrying, setRetrying] = useState(false);
const modifyRequest = async (type: 'approve' | 'decline') => {
const response = await axios.get(`/api/v1/request/${request.id}/${type}`);
@ -68,7 +79,23 @@ const RequestItem: React.FC<RequestItemProps> = ({ request, onDelete }) => {
const deleteRequest = async () => {
await axios.delete(`/api/v1/request/${request.id}`);
onDelete();
revalidateList();
};
const retryRequest = async () => {
setRetrying(true);
try {
const result = await axios.post(`/api/v1/request/${request.id}/retry`);
mutate(result.data);
} catch (e) {
addToast(intl.formatMessage(messages.failedretry), {
autoDismiss: true,
appearance: 'error',
});
} finally {
setRetrying(false);
}
};
if (!title && !error) {
@ -138,7 +165,13 @@ const RequestItem: React.FC<RequestItemProps> = ({ request, onDelete }) => {
)}
</Table.TD>
<Table.TD>
<StatusBadge status={requestData.media.status} />
{requestData.media.status === MediaStatus.UNKNOWN ? (
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.failed)}
</Badge>
) : (
<StatusBadge status={requestData.media.status} />
)}
</Table.TD>
<Table.TD>
<div className="flex flex-col">
@ -167,6 +200,31 @@ const RequestItem: React.FC<RequestItemProps> = ({ request, onDelete }) => {
</div>
</Table.TD>
<Table.TD alignText="right">
{requestData.media.status === MediaStatus.UNKNOWN &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<Button
className="mr-2"
buttonType="primary"
buttonSize="sm"
disabled={isRetrying}
onClick={() => retryRequest()}
>
<svg
className="w-4 h-4 mr-0 sm:mr-1"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="18px"
height="18px"
>
<path d="M0 0h24v24H0z" fill="none" />
<path d="M7 7h10v3l4-4-4-4v3H5v6h2V7zm10 10H7v-3l-4 4 4 4v-3h12v-6h-2v4z" />
</svg>
<span className="hidden sm:block">
{intl.formatMessage(globalMessages.retry)}
</span>
</Button>
)}
{requestData.status !== MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && (
<Button

@ -56,7 +56,7 @@ const RequestList: React.FC = () => {
<RequestItem
request={request}
key={`request-list-${request.id}`}
onDelete={() => revalidate()}
revalidateList={() => revalidate()}
/>
);
})}

@ -67,7 +67,12 @@ const MovieRequestModal: React.FC<RequestModalProps> = ({
if (response.data) {
if (onComplete) {
onComplete(response.data.media.status);
onComplete(
hasPermission(Permission.AUTO_APPROVE) ||
hasPermission(Permission.AUTO_APPROVE_MOVIE)
? MediaStatus.PROCESSING
: MediaStatus.PENDING
);
}
addToast(
<span>

@ -223,13 +223,13 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
}
>
<div className="flex flex-col">
<div className="-mx-4 sm:mx-0 overflow-auto max-h-96">
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8">
<div className="shadow overflow-hidden sm:rounded-lg">
<div className="-mx-4 overflow-auto sm:mx-0 max-h-96">
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div className="overflow-hidden shadow sm:rounded-lg">
<table className="min-w-full">
<thead>
<tr>
<th className="px-4 py-3 bg-gray-500 w-16">
<th className="w-16 px-4 py-3 bg-gray-500">
<span
role="checkbox"
tabIndex={0}
@ -240,7 +240,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
toggleAllSeasons();
}
}}
className="group relative inline-flex items-center justify-center flex-shrink-0 h-5 w-10 cursor-pointer focus:outline-none"
className="relative inline-flex items-center justify-center flex-shrink-0 w-10 h-5 cursor-pointer group focus:outline-none"
>
<span
aria-hidden="true"
@ -256,13 +256,13 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
></span>
</span>
</th>
<th className="px-6 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider">
<th className="px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
{intl.formatMessage(messages.season)}
</th>
<th className="px-6 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider">
<th className="px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
{intl.formatMessage(messages.numberofepisodes)}
</th>
<th className="px-6 py-3 bg-gray-500 text-left text-xs leading-4 font-medium text-gray-200 uppercase tracking-wider">
<th className="px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-gray-200 uppercase bg-gray-500">
{intl.formatMessage(messages.status)}
</th>
</tr>
@ -279,7 +279,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
);
return (
<tr key={`season-${season.id}`}>
<td className="px-4 py-4 whitespace-nowrap text-sm leading-5 font-medium text-gray-100">
<td className="px-4 py-4 text-sm font-medium leading-5 text-gray-100 whitespace-nowrap">
<span
role="checkbox"
tabIndex={0}
@ -320,17 +320,17 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
></span>
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm leading-5 font-medium text-gray-100">
<td className="px-6 py-4 text-sm font-medium leading-5 text-gray-100 whitespace-nowrap">
{season.seasonNumber === 0
? intl.formatMessage(messages.extras)
: intl.formatMessage(messages.seasonnumber, {
number: season.seasonNumber,
})}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm leading-5 text-gray-200">
<td className="px-6 py-4 text-sm leading-5 text-gray-200 whitespace-nowrap">
{season.episodeCount}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm leading-5 text-gray-200">
<td className="px-6 py-4 text-sm leading-5 text-gray-200 whitespace-nowrap">
{!seasonRequest && !mediaSeason && (
<Badge>
{intl.formatMessage(messages.notrequested)}
@ -346,10 +346,8 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
{!mediaSeason &&
seasonRequest?.status ===
MediaRequestStatus.APPROVED && (
<Badge badgeType="danger">
{intl.formatMessage(
globalMessages.unavailable
)}
<Badge badgeType="primary">
{intl.formatMessage(globalMessages.requested)}
</Badge>
)}
{!mediaSeason &&

@ -0,0 +1,189 @@
import React from 'react';
import { Field, Form, Formik } from 'formik';
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 Alert from '../../../Common/Alert';
const messages = defineMessages({
save: 'Save Changes',
saving: 'Saving...',
agentenabled: 'Agent Enabled',
webhookUrl: 'Webhook URL',
validationWebhookUrlRequired: 'You must provide a webhook URL',
webhookUrlPlaceholder: 'Webhook URL',
slacksettingssaved: 'Slack notification settings saved!',
slacksettingsfailed: 'Slack notification settings failed to save.',
testsent: 'Test notification sent!',
test: 'Test',
settingupslack: 'Setting up Slack Notifications',
settingupslackDescription:
'To use Slack notifications, you will need to create an <WebhookLink>Incoming Webhook</WebhookLink> integration and use the provided webhook URL below.',
});
const NotificationsSlack: React.FC = () => {
const intl = useIntl();
const { addToast } = useToasts();
const { data, error, revalidate } = useSWR(
'/api/v1/settings/notifications/slack'
);
const NotificationsSlackSchema = Yup.object().shape({
webhookUrl: Yup.string().required(
intl.formatMessage(messages.validationWebhookUrlRequired)
),
});
if (!data && !error) {
return <LoadingSpinner />;
}
return (
<>
<p className="mb-">
<Alert title={intl.formatMessage(messages.settingupslack)} type="info">
{intl.formatMessage(messages.settingupslackDescription, {
WebhookLink: function WebhookLink(msg) {
return (
<a
href="https://my.slack.com/services/new/incoming-webhook/"
className="text-indigo-100 hover:text-white hover:underline"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
);
},
})}
</Alert>
</p>
<Formik
initialValues={{
enabled: data.enabled,
types: data.types,
webhookUrl: data.options.webhookUrl,
}}
validationSchema={NotificationsSlackSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/settings/notifications/slack', {
enabled: values.enabled,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
},
});
addToast(intl.formatMessage(messages.slacksettingssaved), {
appearance: 'success',
autoDismiss: true,
});
} catch (e) {
addToast(intl.formatMessage(messages.slacksettingsfailed), {
appearance: 'error',
autoDismiss: true,
});
} finally {
revalidate();
}
}}
>
{({ errors, touched, isSubmitting, values, isValid }) => {
const testSettings = async () => {
await axios.post('/api/v1/settings/notifications/slack/test', {
enabled: true,
types: values.types,
options: {
webhookUrl: values.webhookUrl,
},
});
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 sm:pt-5">
<label
htmlFor="isDefault"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
{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 sm:pt-5">
<label
htmlFor="name"
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
>
{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="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 NotificationsSlack;

@ -7,7 +7,7 @@ import Badge from '../../../Common/Badge';
import Button from '../../../Common/Button';
import Modal from '../../../Common/Modal';
import Transition from '../../../Transition';
import { defineMessages, useIntl } from 'react-intl';
import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
import globalMessages from '../../../../i18n/globalMessages';
const messages = defineMessages({
@ -59,7 +59,7 @@ const Release: React.FC<ReleaseProps> = ({
const intl = useIntl();
const [isModalOpen, setModalOpen] = useState(false);
return (
<div className="bg-gray-800 rounded-md flex flex-col sm:flex-row px-4 py-2">
<div className="flex flex-col px-4 py-2 bg-gray-800 rounded-md sm:flex-row">
<Transition
enter="opacity-0 transition duration-300"
enterFrom="opacity-0"
@ -99,7 +99,16 @@ const Release: React.FC<ReleaseProps> = ({
</div>
</Modal>
</Transition>
<div className="flex mb-4 sm:mb-0 items-center justify-center sm:justify-start">
<div className="flex items-center justify-center mb-4 sm:mb-0 sm:justify-start">
<span className="mr-2 text-sm">
<FormattedRelativeTime
value={Math.floor(
(new Date(release.created_at).getTime() - Date.now()) / 1000
)}
updateIntervalInSeconds={1}
numeric="always"
/>
</span>
<span className="text-xl">{release.name}</span>
{isLatest && (
<span className="ml-2">
@ -147,7 +156,7 @@ const Releases: React.FC<ReleasesProps> = ({ currentVersion }) => {
return (
<div>
<div className="text-xl pb-4 mb-4 border-b border-gray-800">
<div className="pb-4 mb-4 text-xl border-b border-gray-800">
{intl.formatMessage(messages.releases)}
</div>
{currentVersion.startsWith('develop-') && (
@ -159,7 +168,7 @@ const Releases: React.FC<ReleasesProps> = ({ currentVersion }) => {
href="https://github.com/sct/overseerr"
target="_blank"
rel="noreferrer"
className="text-yellow-100 underline hover:text-white transition duration-300"
className="text-yellow-100 underline transition duration-300 hover:text-white"
>
{msg}
</a>

@ -10,7 +10,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { useUser, Permission } from '../../hooks/useUser';
import { useToasts } from 'react-toast-notifications';
import { messages as permissionMessages } from '../UserEdit';
import { hasPermission } from '../../../server/lib/permissions';
import PermissionOption, { PermissionItem } from '../PermissionOption';
const messages = defineMessages({
generalsettings: 'General Settings',
@ -27,13 +27,6 @@ const messages = defineMessages({
defaultPermissions: 'Default User Permissions',
});
interface PermissionOption {
id: string;
name: string;
description: string;
permission: Permission;
}
const SettingsMain: React.FC = () => {
const { addToast } = useToasts();
const { hasPermission: userHasPermission } = useUser();
@ -63,7 +56,7 @@ const SettingsMain: React.FC = () => {
return <LoadingSpinner />;
}
const permissionList: PermissionOption[] = [
const permissionList: PermissionItem[] = [
{
id: 'admin',
name: intl.formatMessage(permissionMessages.admin),
@ -96,12 +89,6 @@ const SettingsMain: React.FC = () => {
description: intl.formatMessage(permissionMessages.requestDescription),
permission: Permission.REQUEST,
},
{
id: 'vote',
name: intl.formatMessage(permissionMessages.vote),
description: intl.formatMessage(permissionMessages.voteDescription),
permission: Permission.VOTE,
},
{
id: 'autoapprove',
name: intl.formatMessage(permissionMessages.autoapprove),
@ -109,6 +96,24 @@ const SettingsMain: React.FC = () => {
permissionMessages.autoapproveDescription
),
permission: Permission.AUTO_APPROVE,
children: [
{
id: 'autoapprovemovies',
name: intl.formatMessage(permissionMessages.autoapproveMovies),
description: intl.formatMessage(
permissionMessages.autoapproveMoviesDescription
),
permission: Permission.AUTO_APPROVE_MOVIE,
},
{
id: 'autoapprovetv',
name: intl.formatMessage(permissionMessages.autoapproveSeries),
description: intl.formatMessage(
permissionMessages.autoapproveSeriesDescription
),
permission: Permission.AUTO_APPROVE_TV,
},
],
},
];
@ -230,65 +235,18 @@ const SettingsMain: React.FC = () => {
</div>
<div className="mt-4 sm:mt-0 sm:col-span-2">
<div className="max-w-lg">
{permissionList.map((permissionOption) => (
<div
className={`relative flex items-start first:mt-0 mt-4 ${
permissionOption.permission !==
Permission.ADMIN &&
hasPermission(
Permission.ADMIN,
values.defaultPermissions
{permissionList.map((permissionItem) => (
<PermissionOption
key={`permission-option-${permissionItem.id}`}
option={permissionItem}
currentPermission={values.defaultPermissions}
onUpdate={(newPermissions) =>
setFieldValue(
'defaultPermissions',
newPermissions
)
? 'opacity-50'
: ''
}`}
key={`permission-option-${permissionOption.id}`}
>
<div className="flex items-center h-5">
<input
id={permissionOption.id}
name="permissions"
type="checkbox"
className="w-4 h-4 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
disabled={
permissionOption.permission !==
Permission.ADMIN &&
hasPermission(
Permission.ADMIN,
values.defaultPermissions
)
}
onClick={() => {
setFieldValue(
'defaultPermissions',
hasPermission(
permissionOption.permission,
values.defaultPermissions
)
? values.defaultPermissions -
permissionOption.permission
: values.defaultPermissions +
permissionOption.permission
);
}}
checked={hasPermission(
permissionOption.permission,
values.defaultPermissions
)}
/>
</div>
<div className="ml-3 text-sm leading-5">
<label
htmlFor={permissionOption.id}
className="font-medium"
>
{permissionOption.name}
</label>
<p className="text-gray-500">
{permissionOption.description}
</p>
</div>
</div>
}
/>
))}
</div>
</div>

@ -2,6 +2,8 @@ import Link from 'next/link';
import { useRouter } from 'next/router';
import React from 'react';
import { defineMessages, useIntl } from 'react-intl';
import DiscordLogo from '../../assets/extlogos/discord_white.svg';
import SlackLogo from '../../assets/extlogos/slack.svg';
const messages = defineMessages({
notificationsettings: 'Notification Settings',
@ -11,6 +13,7 @@ const messages = defineMessages({
interface SettingsRoute {
text: string;
content: React.ReactNode;
route: string;
regex: RegExp;
}
@ -18,23 +21,59 @@ interface SettingsRoute {
const settingsRoutes: SettingsRoute[] = [
{
text: 'Email',
content: (
<span className="flex items-center">
<svg
className="h-4 mr-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="M16 12a4 4 0 10-8 0 4 4 0 008 0zm0 0v1.5a2.5 2.5 0 005 0V12a9 9 0 10-9 9m4.5-1.206a8.959 8.959 0 01-4.5 1.207"
/>
</svg>
Email
</span>
),
route: '/settings/notifications/email',
regex: /^\/settings\/notifications\/email/,
},
{
text: 'Discord',
content: (
<span className="flex items-center">
<DiscordLogo className="h-4 mr-2" />
Discord
</span>
),
route: '/settings/notifications/discord',
regex: /^\/settings\/notifications\/discord/,
},
{
text: 'Slack',
content: (
<span className="flex items-center">
<SlackLogo className="h-4 mr-2" />
Slack
</span>
),
route: '/settings/notifications/slack',
regex: /^\/settings\/notifications\/slack/,
},
];
const SettingsNotifications: React.FC = ({ children }) => {
const router = useRouter();
const intl = useIntl();
const activeLinkColor = 'bg-gray-700';
const activeLinkColor = 'bg-indigo-700';
const inactiveLinkColor = '';
const inactiveLinkColor = 'bg-gray-800';
const SettingsLink: React.FC<{
route: string;
@ -62,10 +101,10 @@ const SettingsNotifications: React.FC = ({ children }) => {
return (
<>
<div className="mb-6">
<h3 className="text-lg leading-6 font-medium text-gray-200">
<h3 className="text-lg font-medium leading-6 text-gray-200">
{intl.formatMessage(messages.notificationsettings)}
</h3>
<p className="mt-1 max-w-2xl text-sm leading-5 text-gray-500">
<p className="max-w-2xl mt-1 text-sm leading-5 text-gray-500">
{intl.formatMessage(messages.notificationsettingsDescription)}
</p>
</div>
@ -87,7 +126,7 @@ const SettingsNotifications: React.FC = ({ children }) => {
)?.route
}
aria-label="Selected tab"
className="bg-gray-800 text-white mt-1 rounded-md form-select block w-full pl-3 pr-10 py-2 text-base leading-6 border-gray-700 focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5 transition ease-in-out duration-150"
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"
>
{settingsRoutes.map((route, index) => (
<SettingsLink
@ -109,7 +148,7 @@ const SettingsNotifications: React.FC = ({ children }) => {
regex={route.regex}
key={`standard-settings-link-${index}`}
>
{route.text}
{route.content}
</SettingsLink>
))}
</nav>

@ -5,36 +5,40 @@ import { useIntl } from 'react-intl';
import globalMessages from '../../i18n/globalMessages';
interface StatusBadgeProps {
status: MediaStatus;
status?: MediaStatus;
}
const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
const intl = useIntl();
return (
<>
{status === MediaStatus.AVAILABLE && (
switch (status) {
case MediaStatus.AVAILABLE:
return (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.available)}
</Badge>
)}
{status === MediaStatus.PARTIALLY_AVAILABLE && (
);
case MediaStatus.PARTIALLY_AVAILABLE:
return (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.partiallyavailable)}
</Badge>
)}
{status === MediaStatus.PROCESSING && (
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.unavailable)}
);
case MediaStatus.PROCESSING:
return (
<Badge badgeType="primary">
{intl.formatMessage(globalMessages.requested)}
</Badge>
)}
{status === MediaStatus.PENDING && (
);
case MediaStatus.PENDING:
return (
<Badge badgeType="warning">
{intl.formatMessage(globalMessages.pending)}
</Badge>
)}
</>
);
);
default:
return null;
}
};
export default StatusBadge;

@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState, useCallback, useEffect } from 'react';
import type { MediaType } from '../../../server/models/Search';
import Available from '../../assets/available.svg';
import Requested from '../../assets/requested.svg';
@ -51,6 +51,10 @@ const TitleCard: React.FC<TitleCardProps> = ({
year = year.slice(0, 4);
}
useEffect(() => {
setCurrentStatus(status);
}, [status]);
const requestComplete = useCallback((newStatus: MediaStatus) => {
setCurrentStatus(newStatus);
setShowRequestModal(false);
@ -74,7 +78,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
onCancel={closeModal}
/>
<div
className="titleCard outline-none cursor-default"
className="outline-none cursor-default titleCard"
style={{
backgroundImage: `url(//image.tmdb.org/t/p/w300_and_h450_face${image})`,
}}
@ -93,13 +97,13 @@ const TitleCard: React.FC<TitleCardProps> = ({
role="link"
tabIndex={0}
>
<div className="absolute top-0 h-full w-full bottom-0 left-0 right-0 overflow-hidden shadow-xl">
<div className="absolute top-0 bottom-0 left-0 right-0 w-full h-full overflow-hidden shadow-xl">
<div
className={`absolute left-0 top-0 rounded-tl-md rounded-br-md z-40 ${
mediaType === 'movie' ? 'bg-blue-500' : 'bg-purple-600'
}`}
>
<div className="flex items-center text-center text-xs text-white h-4 px-2 py-1 font-normal uppercase">
<div className="flex items-center h-4 px-2 py-1 text-xs font-normal text-center text-white uppercase">
{mediaType === 'movie'
? intl.formatMessage(messages.movie)
: intl.formatMessage(messages.tvshow)}
@ -107,7 +111,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
</div>
<div
className="absolute right-0 top-0 z-40"
className="absolute top-0 right-0 z-40"
style={{
right: '-1px',
}}
@ -132,7 +136,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute top-0 left-0 right-0 bottom-0 bg-gray-800 bg-opacity-75 z-40 text-white flex items-center justify-center rounded-lg">
<div className="absolute top-0 bottom-0 left-0 right-0 z-40 flex items-center justify-center text-white bg-gray-800 bg-opacity-75 rounded-lg">
<svg
className="w-10 h-10 animate-spin"
fill="none"
@ -159,155 +163,155 @@ const TitleCard: React.FC<TitleCardProps> = ({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Link href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}>
<a
className="absolute w-full text-left top-0 right-0 left-0 bottom-0 rounded-lg overflow-hidden cursor-pointer"
style={{
background:
'linear-gradient(180deg, rgba(45, 55, 72, 0.4) 0%, rgba(45, 55, 72, 0.9) 100%)',
}}
>
<div className="absolute bottom-0 w-full left-0 right-0">
<div className="px-2 text-white">
{year && <div className="text-sm">{year}</div>}
<div className="absolute top-0 bottom-0 left-0 right-0">
<Link href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}>
<a
className="absolute top-0 bottom-0 left-0 right-0 w-full h-full overflow-hidden text-left rounded-lg cursor-pointer"
style={{
background:
'linear-gradient(180deg, rgba(45, 55, 72, 0.4) 0%, rgba(45, 55, 72, 0.9) 100%)',
}}
>
<div className="flex items-end w-full h-full">
<div className="px-2 text-white pb-11">
{year && <div className="text-sm">{year}</div>}
<h1 className="text-xl leading-tight whitespace-normal">
{title}
</h1>
<div
className="text-xs whitespace-normal"
style={{
WebkitLineClamp: 3,
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
}}
>
{summary}
</div>
</div>
<div className="flex justify-between left-0 bottom-0 right-0 top-0 px-2 py-2">
<Link
href={
mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`
}
>
<a className="cursor-pointer flex w-full h-7 text-center text-white bg-indigo-500 rounded-sm hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150">
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
</a>
</Link>
{(!currentStatus ||
currentStatus === MediaStatus.UNKNOWN) && (
<button
onClick={(e) => {
e.preventDefault();
setShowRequestModal(true);
<h1 className="text-xl leading-tight whitespace-normal">
{title}
</h1>
<div
className="text-xs whitespace-normal"
style={{
WebkitLineClamp: 3,
display: '-webkit-box',
overflow: 'hidden',
WebkitBoxOrient: 'vertical',
}}
className="w-full h-7 text-center text-white bg-indigo-500 rounded-sm ml-2 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700 transition ease-in-out duration-150"
>
<svg
className="w-4 mx-auto"
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>
</button>
)}
{currentStatus === MediaStatus.PENDING && (
<button
className="w-full h-7 text-center text-yellow-500 border border-yellow-500 rounded-sm ml-2 cursor-default"
disabled
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
</button>
)}
{currentStatus === MediaStatus.PROCESSING && (
<button
className="w-full h-7 text-center text-red-500 border border-red-500 rounded-sm ml-2 cursor-default"
disabled
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
)}
{(currentStatus === MediaStatus.AVAILABLE ||
currentStatus === MediaStatus.PARTIALLY_AVAILABLE) && (
<button
className="w-full h-7 text-center text-green-400 border border-green-400 rounded-sm ml-2 cursor-default"
disabled
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</button>
)}
{summary}
</div>
</div>
</div>
</div>
</a>
</Link>
</a>
</Link>
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 py-2">
<Link
href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}
>
<a className="flex w-full text-center text-white transition duration-150 ease-in-out bg-indigo-500 rounded-sm cursor-pointer h-7 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700">
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
</a>
</Link>
{(!currentStatus || currentStatus === MediaStatus.UNKNOWN) && (
<button
onClick={(e) => {
e.preventDefault();
setShowRequestModal(true);
}}
className="w-full ml-2 text-center text-white transition duration-150 ease-in-out bg-indigo-500 rounded-sm h-7 hover:bg-indigo-400 focus:border-indigo-700 focus:ring-indigo active:bg-indigo-700"
>
<svg
className="w-4 mx-auto"
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>
</button>
)}
{currentStatus === MediaStatus.PENDING && (
<button
className="w-full ml-2 text-center text-yellow-500 border border-yellow-500 rounded-sm cursor-default h-7"
disabled
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
</button>
)}
{currentStatus === MediaStatus.PROCESSING && (
<button
className="w-full ml-2 text-center text-indigo-500 border border-indigo-500 rounded-sm cursor-default h-7"
disabled
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
)}
{(currentStatus === MediaStatus.AVAILABLE ||
currentStatus === MediaStatus.PARTIALLY_AVAILABLE) && (
<button
className="w-full ml-2 text-center text-green-400 border border-green-400 rounded-sm cursor-default h-7"
disabled
>
<svg
className="w-4 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</button>
)}
</div>
</div>
</Transition>
</div>
</div>

@ -19,7 +19,6 @@ import { useUser, Permission } from '../../hooks/useUser';
import { TvDetails as TvDetailsType } from '../../../server/models/Tv';
import { MediaStatus } from '../../../server/constants/media';
import RequestModal from '../RequestModal';
import Badge from '../Common/Badge';
import ButtonWithDropdown from '../Common/ButtonWithDropdown';
import axios from 'axios';
import SlideOver from '../Common/SlideOver';
@ -32,11 +31,11 @@ import RTAudFresh from '../../assets/rt_aud_fresh.svg';
import RTAudRotten from '../../assets/rt_aud_rotten.svg';
import type { RTRating } from '../../../server/api/rottentomatoes';
import Head from 'next/head';
import globalMessages from '../../i18n/globalMessages';
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb';
import ExternalLinkBlock from '../ExternalLinkBlock';
import { sortCrewPriority } from '../../utils/creditHelpers';
import { Crew } from '../../../server/models/common';
import StatusBadge from '../StatusBadge';
const messages = defineMessages({
firstAirDate: 'First Air Date',
@ -48,6 +47,7 @@ const messages = defineMessages({
recommendations: 'Recommendations',
similar: 'Similar Series',
cancelrequest: 'Cancel Request',
watchtrailer: 'Watch Trailer',
available: 'Available',
unavailable: 'Unavailable',
request: 'Request',
@ -130,6 +130,11 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
(request) => request.status === MediaRequestStatus.PENDING
);
const trailerUrl = data.relatedVideos
?.filter((r) => r.type === 'Trailer')
.sort((a, b) => a.size - b.size)
.pop()?.url;
const modifyRequests = async (type: 'approve' | 'decline'): Promise<void> => {
if (!activeRequests) {
return;
@ -221,38 +226,19 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</div>
)}
</SlideOver>
<div className="flex flex-col items-center pt-4 md:flex-row md:items-end">
<div className="flex-shrink-0 md:mr-4">
<div className="flex flex-col items-center pt-4 lg:flex-row lg:items-end">
<div className="lg:mr-4">
<img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
alt=""
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-52"
className="w-32 rounded shadow md:rounded-lg md:shadow-2xl md:w-44 lg:w-52"
/>
</div>
<div className="flex flex-col mt-4 text-center text-white md:mr-4 md:mt-0 md:text-left">
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
<div className="mb-2">
{data.mediaInfo?.status === MediaStatus.AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.available)}
</Badge>
)}
{data.mediaInfo?.status === MediaStatus.PARTIALLY_AVAILABLE && (
<Badge badgeType="success">
{intl.formatMessage(globalMessages.partiallyavailable)}
</Badge>
)}
{data.mediaInfo?.status === MediaStatus.PROCESSING && (
<Badge badgeType="danger">
{intl.formatMessage(globalMessages.unavailable)}
</Badge>
)}
{data.mediaInfo?.status === MediaStatus.PENDING && (
<Badge badgeType="warning">
{intl.formatMessage(globalMessages.pending)}
</Badge>
)}
<StatusBadge status={data.mediaInfo?.status} />
</div>
<h1 className="text-2xl md:text-4xl">
<h1 className="text-2xl lg:text-4xl">
<span>{data.name}</span>
{data.firstAirDate && (
<span className="ml-2 text-2xl">
@ -260,19 +246,47 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</span>
)}
</h1>
<span className="mt-1 text-xs md:text-base md:mt-0">
<span className="mt-1 text-xs lg:text-base lg:mt-0">
{data.genres.map((g) => g.name).join(', ')}
</span>
</div>
<div className="flex justify-end flex-1 mt-4 md:mt-0">
<div className="flex justify-end flex-shrink-0 mt-4 lg:mt-0">
{trailerUrl && (
<a href={trailerUrl} target="_blank" rel="noreferrer">
<Button buttonType="ghost">
<svg
className="w-5 h-5 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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<FormattedMessage {...messages.watchtrailer} />
</Button>
</a>
)}
{(!data.mediaInfo ||
data.mediaInfo.status === MediaStatus.UNKNOWN) && (
<Button
className="ml-2"
buttonType="primary"
onClick={() => setShowRequestModal(true)}
>
<svg
className="w-4 mr-1"
className="w-5 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@ -309,7 +323,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
text={
<>
<svg
className="w-4 mr-1"
className="w-5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
@ -323,6 +337,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
<FormattedMessage {...messages.requestmore} />
</>
}
className="ml-2"
onClick={() => setShowRequestModal(true)}
>
{hasPermission(Permission.MANAGE_REQUESTS) &&

@ -2,12 +2,12 @@ import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import LoadingSpinner from '../Common/LoadingSpinner';
import { Permission, useUser } from '../../hooks/useUser';
import { hasPermission } from '../../../server/lib/permissions';
import Button from '../Common/Button';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import axios from 'axios';
import { useToasts } from 'react-toast-notifications';
import Header from '../Common/Header';
import PermissionOption, { PermissionItem } from '../PermissionOption';
export const messages = defineMessages({
edituser: 'Edit User',
@ -35,25 +35,24 @@ export const messages = defineMessages({
autoapprove: 'Auto Approve',
autoapproveDescription:
'Grants auto approval for any requests made by this user.',
autoapproveMovies: 'Auto Approve Movies',
autoapproveMoviesDescription:
'Grants auto approve for movie requests made by this user.',
autoapproveSeries: 'Auto Approve Series',
autoapproveSeriesDescription:
'Grants auto approve for series requests made by this user.',
save: 'Save',
saving: 'Saving...',
usersaved: 'User saved',
userfail: 'Something went wrong saving the user.',
});
interface PermissionOption {
id: string;
name: string;
description: string;
permission: Permission;
}
const UserEdit: React.FC = () => {
const router = useRouter();
const intl = useIntl();
const { addToast } = useToasts();
const [isUpdating, setIsUpdating] = useState(false);
const { user: currentUser, hasPermission: currentHasPermission } = useUser();
const { user: currentUser } = useUser();
const { user, error, revalidate } = useUser({
id: Number(router.query.userId),
});
@ -97,7 +96,7 @@ const UserEdit: React.FC = () => {
return <LoadingSpinner />;
}
const permissionList: PermissionOption[] = [
const permissionList: PermissionItem[] = [
{
id: 'admin',
name: intl.formatMessage(messages.admin),
@ -128,17 +127,29 @@ const UserEdit: React.FC = () => {
description: intl.formatMessage(messages.requestDescription),
permission: Permission.REQUEST,
},
{
id: 'vote',
name: intl.formatMessage(messages.vote),
description: intl.formatMessage(messages.voteDescription),
permission: Permission.VOTE,
},
{
id: 'autoapprove',
name: intl.formatMessage(messages.autoapprove),
description: intl.formatMessage(messages.autoapproveDescription),
permission: Permission.AUTO_APPROVE,
children: [
{
id: 'autoapprovemovies',
name: intl.formatMessage(messages.autoapproveMovies),
description: intl.formatMessage(
messages.autoapproveMoviesDescription
),
permission: Permission.AUTO_APPROVE_MOVIE,
},
{
id: 'autoapprovetv',
name: intl.formatMessage(messages.autoapproveSeries),
description: intl.formatMessage(
messages.autoapproveSeriesDescription
),
permission: Permission.AUTO_APPROVE_TV,
},
],
},
];
@ -231,74 +242,16 @@ const UserEdit: React.FC = () => {
</div>
<div className="mt-4 sm:mt-0 sm:col-span-2">
<div className="max-w-lg">
{permissionList.map((permissionOption) => (
<div
className={`relative flex items-start first:mt-0 mt-4 ${
(permissionOption.permission !== Permission.ADMIN &&
hasPermission(
Permission.ADMIN,
currentPermission
)) ||
(currentUser?.id !== 1 &&
permissionOption.permission === Permission.ADMIN) ||
(!currentHasPermission(Permission.MANAGE_SETTINGS) &&
permissionOption.permission ===
Permission.MANAGE_SETTINGS)
? 'opacity-50'
: ''
}`}
key={`permission-option-${permissionOption.id}`}
>
<div className="flex items-center h-5">
<input
id={permissionOption.id}
name="permissions"
type="checkbox"
className="w-4 h-4 text-indigo-600 transition duration-150 ease-in-out rounded-md form-checkbox"
disabled={
(permissionOption.permission !==
Permission.ADMIN &&
hasPermission(
Permission.ADMIN,
currentPermission
)) ||
(currentUser?.id !== 1 &&
permissionOption.permission ===
Permission.ADMIN) ||
(!currentHasPermission(
Permission.MANAGE_SETTINGS
) &&
permissionOption.permission ===
Permission.MANAGE_SETTINGS)
}
onClick={() => {
setCurrentPermission((current) =>
hasPermission(
permissionOption.permission,
currentPermission
)
? current - permissionOption.permission
: current + permissionOption.permission
);
}}
checked={hasPermission(
permissionOption.permission,
currentPermission
)}
/>
</div>
<div className="ml-3 text-sm leading-5">
<label
htmlFor={permissionOption.id}
className="font-medium"
>
{permissionOption.name}
</label>
<p className="text-gray-500">
{permissionOption.description}
</p>
</div>
</div>
{permissionList.map((permissionItem) => (
<PermissionOption
key={`permission-option-${permissionItem.id}`}
option={permissionItem}
user={currentUser}
currentPermission={currentPermission}
onUpdate={(newPermission) =>
setCurrentPermission(newPermission)
}
/>
))}
</div>
</div>

@ -5,6 +5,8 @@ const globalMessages = defineMessages({
partiallyavailable: 'Partially Available',
processing: 'Processing',
unavailable: 'Unavailable',
requested: 'Requested',
failed: 'Failed',
pending: 'Pending',
declined: 'Declined',
approved: 'Approved',
@ -14,6 +16,7 @@ const globalMessages = defineMessages({
approve: 'Approve',
decline: 'Decline',
delete: 'Delete',
retry: 'Retry',
deleting: 'Deleting…',
close: 'Close',
});

@ -5,28 +5,28 @@
"components.Discover.popularmovies": "Beliebte Filme",
"components.Discover.populartv": "Beliebte Serien",
"components.Discover.recentlyAdded": "Kürzlich hinzugefügt",
"components.Discover.recentrequests": "Aktuelle Anfragen",
"components.Discover.recentrequests": "Kürzliche Anfragen",
"components.Discover.trending": "Trends",
"components.Discover.upcoming": "Bald erscheinende Filme",
"components.Discover.upcomingmovies": "Bald erscheinende Filme",
"components.Layout.LanguagePicker.changelanguage": "Sprache ändern",
"components.Layout.SearchInput.searchPlaceholder": "Nach Filmen und Serien suchen",
"components.Layout.Sidebar.dashboard": "Entdecken",
"components.Layout.Sidebar.requests": "Anträge",
"components.Layout.Sidebar.requests": "Anfragen",
"components.Layout.Sidebar.settings": "Einstellungen",
"components.Layout.Sidebar.users": "Benutzer",
"components.Layout.UserDropdown.signout": "Abmelden",
"components.Layout.alphawarning": "Dies ist ALPHA-Software. Fast alles kann kaputt und/oder instabil sein. Bitte melde Probleme auf der GitHub-Seite!",
"components.Login.signinplex": "Melden Sie sich an, um fortzufahren",
"components.Layout.alphawarning": "Dies ist eine ALPHA-Software. Fast alles kann kaputt und/oder instabil sein. Bitte melde Probleme auf der GitHub-Seite!",
"components.Login.signinplex": "Melde dich an, um fortzufahren",
"components.MovieDetails.approve": "Genehmigen",
"components.MovieDetails.available": "Verfügbar",
"components.MovieDetails.budget": "Budget",
"components.MovieDetails.cancelrequest": "Anfrage zurücknehmen",
"components.MovieDetails.cancelrequest": "Anfrage abbrechen",
"components.MovieDetails.cast": "Besetzung",
"components.MovieDetails.decline": "Ablehnen",
"components.MovieDetails.manageModalClearMedia": "Alle Mediendaten löschen",
"components.MovieDetails.manageModalClearMediaWarning": "Dadurch werden alle Mediendaten einschließlich aller Anfragen für dieses Element entfernt. Diese Aktion ist irreversibel. Wenn dieses Element in Ihrer Plex-Bibliothek vorhanden ist, werden die Medieninformationen bei der nächsten Synchronisierung neu erstellt.",
"components.MovieDetails.manageModalNoRequests": "Keine Anträge",
"components.MovieDetails.manageModalNoRequests": "Keine Anfragen",
"components.MovieDetails.manageModalRequests": "Anfragen",
"components.MovieDetails.manageModalTitle": "Film verwalten",
"components.MovieDetails.originallanguage": "Originalsprache",
@ -36,7 +36,7 @@
"components.MovieDetails.recommendations": "Empfehlungen",
"components.MovieDetails.recommendationssubtext": "Wenn dir {title} gefallen hat, könnte dir auch gefallen …",
"components.MovieDetails.releasedate": "Erscheinungsdatum",
"components.MovieDetails.request": "Anfrage",
"components.MovieDetails.request": "Anfragen",
"components.MovieDetails.revenue": "Einnahmen",
"components.MovieDetails.runtime": "{minutes} Minuten",
"components.MovieDetails.similar": "Ähnliche Titel",
@ -53,7 +53,7 @@
"components.PlexLoginButton.loginwithplex": "Anmeldung mit Plex",
"components.RequestBlock.seasons": "Staffeln",
"components.RequestCard.all": "Alle",
"components.RequestCard.requestedby": "Anfrage von {username}",
"components.RequestCard.requestedby": "Angefragt von {username}",
"components.RequestCard.seasons": "Staffeln",
"components.RequestList.RequestItem.notavailable": "entf.",
"components.RequestList.RequestItem.requestedby": "Angefragt von {username}",
@ -64,7 +64,7 @@
"components.RequestList.previous": "Vorherige",
"components.RequestList.requestedAt": "Angefragt am",
"components.RequestList.requests": "Anfragen",
"components.RequestList.showingresults": "Anzeigen von <strong>{from}</strong> bis <strong> {to} </strong> von <strong> {total} </strong> Ergebnissen",
"components.RequestList.showingresults": "Zeigt <strong>{from}</strong> bis <strong> {to} </strong> von <strong> {total} </strong> Ergebnissen",
"components.RequestList.status": "Status",
"components.RequestModal.cancel": "Anfrage abbrechen",
"components.RequestModal.cancelling": "Abbrechen …",
@ -77,10 +77,10 @@
"components.RequestModal.request": "Anfrage",
"components.RequestModal.requestCancel": "Anfrage für <strong>{title}</strong> abgebrochen",
"components.RequestModal.requestSuccess": "<strong>{title}</strong> angefragt.",
"components.RequestModal.requestadmin": "Deine Anfrage wird direkt genehmigt.",
"components.RequestModal.requestadmin": "Deine Anfrage wird direkt genehmigt werden.",
"components.RequestModal.requestfrom": "Derzeit steht eine Anfrage von {username} aus",
"components.RequestModal.requesting": "Wird angefragt…",
"components.RequestModal.requestseasons": "{seasonCount} {seasonCount, plural, one {Season} other {Seasons}} anfragen",
"components.RequestModal.requesting": "Wird angefragt …",
"components.RequestModal.requestseasons": "{seasonCount} {seasonCount, plural, one {Staffel} other {Staffeln}} anfragen",
"components.RequestModal.requesttitle": "{title} anfragen",
"components.RequestModal.season": "Staffel",
"components.RequestModal.seasonnumber": "Staffel {number}",
@ -96,15 +96,15 @@
"components.Settings.Notifications.saving": "Speichern …",
"components.Settings.Notifications.smtpHost": "SMTP-Host",
"components.Settings.Notifications.smtpPort": "SMTP-Port",
"components.Settings.Notifications.validationFromRequired": "Sie müssen eine E-Mail-Absenderadresse angeben",
"components.Settings.Notifications.validationSmtpHostRequired": "Sie müssen einen SMTP-Host bereitstellen",
"components.Settings.Notifications.validationSmtpPortRequired": "Sie müssen einen SMTP-Port bereitstellen",
"components.Settings.Notifications.validationWebhookUrlRequired": "Sie müssen eine Webhook-URL angeben",
"components.Settings.Notifications.validationFromRequired": "Du musst eine E-Mail-Absenderadresse angeben",
"components.Settings.Notifications.validationSmtpHostRequired": "Du musst einen SMTP-Host bereitstellen",
"components.Settings.Notifications.validationSmtpPortRequired": "Du musst einen SMTP-Port bereitstellen",
"components.Settings.Notifications.validationWebhookUrlRequired": "Du musst eine Webhook-URL angeben",
"components.Settings.Notifications.webhookUrl": "Webhook-URL",
"components.Settings.Notifications.webhookUrlPlaceholder": "Servereinstellungen -> Integrationen -> Webhooks",
"components.Settings.RadarrModal.add": "Server hinzufügen",
"components.Settings.RadarrModal.apiKey": "API-Schlüssel",
"components.Settings.RadarrModal.apiKeyPlaceholder": "Ihr Radarr-API-Schlüssel",
"components.Settings.RadarrModal.apiKeyPlaceholder": "Dein Radarr-API-Schlüssel",
"components.Settings.RadarrModal.baseUrl": "Basis-URL",
"components.Settings.RadarrModal.baseUrlPlaceholder": "Beispiel: /radarr",
"components.Settings.RadarrModal.createradarr": "Einen neuen Radarr-Server erstellen",
@ -117,9 +117,9 @@
"components.Settings.RadarrModal.rootfolder": "Stammordner",
"components.Settings.RadarrModal.save": "Änderungen speichern",
"components.Settings.RadarrModal.saving": "Speichern …",
"components.Settings.RadarrModal.selectMinimumAvailability": "Wählen Sie die Mindestverfügbarkeit",
"components.Settings.RadarrModal.selectQualityProfile": "Wählen Sie ein Qualitätsprofil",
"components.Settings.RadarrModal.selectRootFolder": "Wählen Sie einen Stammordner",
"components.Settings.RadarrModal.selectMinimumAvailability": "Wähle die Mindestverfügbarkeit",
"components.Settings.RadarrModal.selectQualityProfile": "Wähle ein Qualitätsprofil",
"components.Settings.RadarrModal.selectRootFolder": "Wähle einen Stammordner",
"components.Settings.RadarrModal.server4k": "4K-Server",
"components.Settings.RadarrModal.servername": "Servername",
"components.Settings.RadarrModal.servernamePlaceholder": "Ein Radarr-Server",
@ -128,14 +128,14 @@
"components.Settings.RadarrModal.testing": "Testen …",
"components.Settings.RadarrModal.toastRadarrTestFailure": "Verbindung zum Radarr-Server fehlgeschlagen",
"components.Settings.RadarrModal.toastRadarrTestSuccess": "Radarr-Verbindung hergestellt!",
"components.Settings.RadarrModal.validationApiKeyRequired": "Sie müssen einen API-Schlüssel angeben",
"components.Settings.RadarrModal.validationHostnameRequired": "Sie müssen einen Hostnamen/IP angeben",
"components.Settings.RadarrModal.validationPortRequired": "Sie müssen einen Port angeben",
"components.Settings.RadarrModal.validationProfileRequired": "Sie müssen ein Profil auswählen",
"components.Settings.RadarrModal.validationRootFolderRequired": "Sie müssen einen Stammordner auswählen",
"components.Settings.RadarrModal.validationApiKeyRequired": "Du musst einen API-Schlüssel angeben",
"components.Settings.RadarrModal.validationHostnameRequired": "Du musst einen Hostnamen/IP angeben",
"components.Settings.RadarrModal.validationPortRequired": "Du musst einen Port angeben",
"components.Settings.RadarrModal.validationProfileRequired": "Du musst ein Profil auswählen",
"components.Settings.RadarrModal.validationRootFolderRequired": "Du musst einen Stammordner auswählen",
"components.Settings.SonarrModal.add": "Server hinzufügen",
"components.Settings.SonarrModal.apiKey": "API-Schlüssel",
"components.Settings.SonarrModal.apiKeyPlaceholder": "Ihr Sonarr-API-Schlüssel",
"components.Settings.SonarrModal.apiKeyPlaceholder": "Dein Sonarr-API-Schlüssel",
"components.Settings.SonarrModal.baseUrl": "Basis-URL",
"components.Settings.SonarrModal.baseUrlPlaceholder": "Beispiel: /sonarr",
"components.Settings.SonarrModal.createsonarr": "Neuen Sonarr-Server erstellen",
@ -147,22 +147,22 @@
"components.Settings.SonarrModal.rootfolder": "Stammordner",
"components.Settings.SonarrModal.save": "Änderungen speichern",
"components.Settings.SonarrModal.saving": "Speichern …",
"components.Settings.SonarrModal.seasonfolders": "Staffelnordner",
"components.Settings.SonarrModal.selectQualityProfile": "Wählen Sie ein Qualitätsprofil",
"components.Settings.SonarrModal.selectRootFolder": "Wählen Sie einen Stammordner",
"components.Settings.SonarrModal.seasonfolders": "Staffel Ordner",
"components.Settings.SonarrModal.selectQualityProfile": "Wähle ein Qualitätsprofil",
"components.Settings.SonarrModal.selectRootFolder": "Wähle einen Stammordner",
"components.Settings.SonarrModal.server4k": "4K-Server",
"components.Settings.SonarrModal.servername": "Servername",
"components.Settings.SonarrModal.servernamePlaceholder": "Ein Sonarr-Server",
"components.Settings.SonarrModal.ssl": "SSL",
"components.Settings.SonarrModal.test": "Test",
"components.Settings.SonarrModal.testing": "Testen …",
"components.Settings.SonarrModal.toastRadarrTestFailure": "Es konnte keine Verbindung zu Sonarr-Server hergestellt werden",
"components.Settings.SonarrModal.toastRadarrTestFailure": "Es konnte keine Verbindung zum Sonarr-Server hergestellt werden",
"components.Settings.SonarrModal.toastRadarrTestSuccess": "Sonarr-Verbindung hergestellt!",
"components.Settings.SonarrModal.validationApiKeyRequired": "Sie müssen einen API-Schlüssel angeben",
"components.Settings.SonarrModal.validationHostnameRequired": "Sie müssen einen Hostnamen/IP angeben",
"components.Settings.SonarrModal.validationPortRequired": "Sie müssen einen Port angeben",
"components.Settings.SonarrModal.validationProfileRequired": "Sie müssen ein Profil auswählen",
"components.Settings.SonarrModal.validationRootFolderRequired": "Sie müssen einen Stammordner auswählen",
"components.Settings.SonarrModal.validationApiKeyRequired": "Du musst einen API-Schlüssel angeben",
"components.Settings.SonarrModal.validationHostnameRequired": "Du musst einen Hostnamen/IP angeben",
"components.Settings.SonarrModal.validationPortRequired": "Du musst einen Port angeben",
"components.Settings.SonarrModal.validationProfileRequired": "Du musst ein Profil auswählen",
"components.Settings.SonarrModal.validationRootFolderRequired": "Du musst einen Stammordner auswählen",
"components.Settings.activeProfile": "Aktives Profil",
"components.Settings.addradarr": "Radarr-Server hinzufügen",
"components.Settings.address": "Adresse",
@ -170,44 +170,44 @@
"components.Settings.apikey": "API-Schlüssel",
"components.Settings.applicationurl": "Anwendungs-URL",
"components.Settings.cancelscan": "Scan abbrechen",
"components.Settings.copied": "Kopierter API-Schlüssel in die Zwischenablage",
"components.Settings.copied": "API-Schlüssel in die Zwischenablage kopiert",
"components.Settings.currentlibrary": "Aktuelle Bibliothek: {name}",
"components.Settings.default": "Standardmäßig",
"components.Settings.default4k": "Standard-4K",
"components.Settings.delete": "Löschen",
"components.Settings.deleteserverconfirm": "Sind Sie sicher, dass Sie diesen Server löschen möchten?",
"components.Settings.deleteserverconfirm": "Bist du sicher, dass du diesen Server löschen möchtest?",
"components.Settings.edit": "Bearbeiten",
"components.Settings.generalsettings": "Allgemeine Einstellungen",
"components.Settings.generalsettingsDescription": "Dies sind Einstellungen, die sich auf die allgemeine Overseerr-Konfiguration beziehen.",
"components.Settings.hostname": "Hostname/IP",
"components.Settings.jobname": "Auftragsname",
"components.Settings.jobname": "Aufgabenname",
"components.Settings.librariesRemaining": "Verbleibende Bibliotheken: {count}",
"components.Settings.manualscan": "Manueller Bibliotheksscan",
"components.Settings.manualscanDescription": "Normalerweise wird dies nur einmal alle 24 Stunden ausgeführt. Overseerr überprüft die kürzlich hinzugefügten Plex-Server aggressiver. Wenn Sie Plex zum ersten Mal konfigurieren, wird ein einmaliger vollständiger manueller Bibliotheksscan empfohlen!",
"components.Settings.menuAbout": "Über",
"components.Settings.menuGeneralSettings": "Allgemeine Einstellungen",
"components.Settings.menuJobs": "Anträge",
"components.Settings.menuJobs": "Aufgaben",
"components.Settings.menuLogs": "Protokolle",
"components.Settings.menuNotifications": "Benachrichtigungen",
"components.Settings.menuPlexSettings": "Plex",
"components.Settings.menuServices": "Dienste",
"components.Settings.nextexecution": "Nächste Ausführung",
"components.Settings.notificationsettings": "Benachrichtigungseinstellungen",
"components.Settings.notificationsettingsDescription": "Hier können Sie auswählen, welche Arten von Benachrichtigungen gesendet werden sollen und über welche Arten von Diensten.",
"components.Settings.notificationsettingsDescription": "Hier kannst du auswählen, welche Arten von Benachrichtigungen gesendet werden sollen und über welche Arten von Diensten.",
"components.Settings.notrunning": "Nicht aktiv",
"components.Settings.plexlibraries": "Plex-Bibliotheken",
"components.Settings.plexlibrariesDescription": "Die Bibliotheken-Overseerr sucht nach Titeln. Richten Sie Ihre Plex-Verbindungseinstellungen ein und speichern Sie sie, und klicken Sie auf die Schaltfläche unten, wenn keine aufgeführt sind.",
"components.Settings.plexlibrariesDescription": "Die Bibliotheken-Overseerr sucht nach Titeln. Richte deine Plex-Verbindungseinstellungen ein und speichere sie, klicke auf die Schaltfläche unten, wenn keine aufgeführt sind.",
"components.Settings.plexsettings": "Plex-Einstellungen",
"components.Settings.plexsettingsDescription": "Konfigurieren Sie die Einstellungen für Ihren Plex-Server. Overseerr verwendet Ihren Plex-Server, um Ihre Bibliothek in regelmäßigen Abständen zu scannen und festzustellen, welche Inhalte verfügbar sind.",
"components.Settings.plexsettingsDescription": "Konfiguriere die Einstellungen für deinen Plex-Server. Overseerr verwendet den Plex-Server, um deine Bibliothek in regelmäßigen Abständen zu scannen und festzustellen, welche Inhalte verfügbar sind.",
"components.Settings.port": "Port",
"components.Settings.radarrSettingsDescription": "Richten Sie unten Ihre Radarr-Verbindung ein. Sie können jederzeit mehrere, aber nur zwei standardmäßig aktiv haben (eine für Standard-HD und eine für 4K). Administratoren können überschreiben, welcher Server für neue Anforderungen verwendet wird.",
"components.Settings.radarrSettingsDescription": "Richte unten deine Radarr-Verbindung ein. Du kannst mehrere, aber nur zwei standardmäßig, aktiv haben (eine für Standard-HD und eine für 4K). Administratoren können überschreiben, welcher Server für neue Anfragen verwendet wird.",
"components.Settings.radarrsettings": "Radarr-Einstellungen",
"components.Settings.runnow": "Jetzt ausführen",
"components.Settings.save": "Änderungen speichern",
"components.Settings.saving": "Speichern …",
"components.Settings.servername": "Servername (Wird nach dem Speichern automatisch festgelegt)",
"components.Settings.servernamePlaceholder": "Plex-Servername",
"components.Settings.sonarrSettingsDescription": "Richten Sie unten Ihre Sonarr-Verbindung ein. Sie können jederzeit mehrere, aber nur zwei standardmäßig aktiv haben (eine für Standard-HD und eine für 4K). Administratoren können überschreiben, welcher Server für neue Anforderungen verwendet wird.",
"components.Settings.sonarrSettingsDescription": "Richte unten deine Sonarr-Verbindung ein. Du kannst mehrere, aber nur zwei standardmäßig, aktiv haben (eine für Standard-HD und eine für 4K). Administratoren können überschreiben, welcher Server für neue Anfragen verwendet wird.",
"components.Settings.sonarrsettings": "Sonarr-Einstellungen",
"components.Settings.ssl": "SSL",
"components.Settings.startscan": "Scan starten",
@ -219,69 +219,69 @@
"components.Setup.finish": "Konfiguration beenden",
"components.Setup.finishing": "Fertigstellung …",
"components.Setup.loginwithplex": "Anmeldung mit Plex",
"components.Setup.signinMessage": "Melden Sie sich zunächst mit Ihrem Plex-Konto an",
"components.Setup.signinMessage": "Melde dich zunächst mit deinem Plex-Konto an",
"components.Setup.welcome": "Willkommen bei Overseerr",
"components.Slider.noresults": "Keine Ergebnisse",
"components.TitleCard.movie": "Film",
"components.TitleCard.tvshow": "Serie",
"components.TvDetails.approve": "Genehmigen",
"components.TvDetails.approverequests": "{requestCount} {requestCount, plural, one {Request} other {Requests}} genehmigen",
"components.TvDetails.approverequests": "{requestCount} {requestCount, plural, one {Anfrage} other {Anfragen}} genehmigen",
"components.TvDetails.available": "Verfügbar",
"components.TvDetails.cancelrequest": "Antrag abbrechen",
"components.TvDetails.cancelrequest": "Anfrage abbrechen",
"components.TvDetails.cast": "Besetzung",
"components.TvDetails.decline": "Ablehnen",
"components.TvDetails.declinerequests": "{requestCount} {requestCount, plural, one {Request} andere {Requests}} ablehnen",
"components.TvDetails.declinerequests": "{requestCount} {requestCount, plural, one {Anfrage} andere {Anfragen}} ablehnen",
"components.TvDetails.manageModalClearMedia": "Alle Mediendaten löschen",
"components.TvDetails.manageModalClearMediaWarning": "Dadurch werden alle Mediendaten einschließlich aller Anträge für dieses Element irreversibel entfernt. Wenn dieses Element in Ihrer Plex-Bibliothek vorhanden ist, werden die Medieninformationen bei der nächsten Synchronisierung neu erstellt.",
"components.TvDetails.manageModalNoRequests": "Keine Anträge",
"components.TvDetails.manageModalRequests": "Anträge",
"components.TvDetails.manageModalTitle": "Serien verwalten",
"components.TvDetails.manageModalClearMediaWarning": "Dadurch werden alle Mediendaten einschließlich aller Anfragen für dieses Element irreversibel entfernt. Wenn dieses Element in deiner Plex-Bibliothek vorhanden ist, werden die Medieninformationen bei der nächsten Synchronisierung neu erstellt.",
"components.TvDetails.manageModalNoRequests": "Keine Anfragen",
"components.TvDetails.manageModalRequests": "Anfragen",
"components.TvDetails.manageModalTitle": "Serie verwalten",
"components.TvDetails.originallanguage": "Originalsprache",
"components.TvDetails.overview": "Übersicht",
"components.TvDetails.overviewunavailable": "Übersicht nicht verfügbar",
"components.TvDetails.pending": "Ausstehend",
"components.TvDetails.recommendations": "Empfehlungen",
"components.TvDetails.recommendationssubtext": "Wenn Ihnen {title} gefallen hat, könnte Ihnen auch gefallen …",
"components.TvDetails.request": "Antrag",
"components.TvDetails.requestmore": "Mehr anfordern",
"components.TvDetails.recommendationssubtext": "Wenn dir {title} gefallen hat, könnte dir auch gefallen …",
"components.TvDetails.request": "Anfragen",
"components.TvDetails.requestmore": "Mehr anfragen",
"components.TvDetails.similar": "Ähnliche Serien",
"components.TvDetails.similarsubtext": "Andere Serien {title} ähnlich",
"components.TvDetails.similarsubtext": "Andere Serien, die {title} ähneln",
"components.TvDetails.status": "Status",
"components.TvDetails.unavailable": "Nicht verfügbar",
"components.TvDetails.userrating": "Benutzerbewertung",
"components.UserEdit.admin": "Administrator/in",
"components.UserEdit.admin": "Administrator",
"components.UserEdit.adminDescription": "Voller Administratorzugriff. Umgeht alle Berechtigungsprüfungen.",
"components.UserEdit.autoapprove": "Automatische Genehmigung",
"components.UserEdit.autoapproveDescription": "Gewährt die automatische Genehmigung für alle Anträge dieses Benutzers.",
"components.UserEdit.autoapproveDescription": "Gewährt die automatische Genehmigung für alle Anfragen dieses Benutzers.",
"components.UserEdit.avatar": "Avatar",
"components.UserEdit.edituser": "Benutzer/in bearbeiten",
"components.UserEdit.edituser": "Benutzer bearbeiten",
"components.UserEdit.email": "E-Mail",
"components.UserEdit.managerequests": "Anträge verwalten",
"components.UserEdit.managerequestsDescription": "Erteilt die Berechtigung zum Verwalten von Overseerr-Anträgen. Dies umfasst das Genehmigen und Ablehnen von Anträgen.",
"components.UserEdit.managerequests": "Anfragen verwalten",
"components.UserEdit.managerequestsDescription": "Erteilt die Berechtigung zum Verwalten von Overseerr-Anfragen. Dies umfasst das Genehmigen und Ablehnen von Anfragen.",
"components.UserEdit.permissions": "Berechtigungen",
"components.UserEdit.request": "Antrag",
"components.UserEdit.requestDescription": "Erteilt die Berechtigung zum Anfordern von Filmen und Serien.",
"components.UserEdit.request": "Anfragen",
"components.UserEdit.requestDescription": "Erteilt die Berechtigung zum Anfragen von Filmen und Serien.",
"components.UserEdit.save": "Speichern",
"components.UserEdit.saving": "Speichern …",
"components.UserEdit.settings": "Einstellungen verwalten",
"components.UserEdit.settingsDescription": "Erteilt die Berechtigung zum Ändern aller Overseerr-Einstellungen. Ein/e Benutzer/in muss über diese Berechtigung verfügen, um sie anderen Personen erteilen zu können.",
"components.UserEdit.userfail": "Beim Speichern des Benutzers ist ein Fehler aufgetreten.",
"components.UserEdit.settingsDescription": "Erteilt die Berechtigung zum Ändern aller Overseerr-Einstellungen. Ein Benutzer muss über diese Berechtigung verfügen, um sie anderen Benutzern erteilen zu können.",
"components.UserEdit.userfail": "Beim Speichern des Benutzers ist etwas schief gelaufen.",
"components.UserEdit.username": "Benutzername",
"components.UserEdit.users": "Benutzer verwalten",
"components.UserEdit.usersDescription": "Erteilt die Berechtigung zum Verwalten von Overseerr-Benutzern. Benutzer/innen mit dieser Berechtigung können Benutzer/innen mit Administratorrechten nicht ändern oder gewähren.",
"components.UserEdit.usersaved": "Benutzer/in gespeichert",
"components.UserEdit.usersDescription": "Erteilt die Berechtigung zum Verwalten von Overseerr-Benutzern. Benutzer mit dieser Berechtigung können Benutzer mit Administratorrechten nicht bearbeiten oder es gewähren.",
"components.UserEdit.usersaved": "Benutzer gespeichert",
"components.UserEdit.vote": "Abstimmen",
"components.UserEdit.voteDescription": "Erteilt die Erlaubnis, über Anträge abzustimmen (Abstimmung noch nicht durchgeführt)",
"components.UserEdit.voteDescription": "Erteilt die Erlaubnis, über Anfragen abzustimmen (Abstimmungen noch nicht implementiert)",
"components.UserList.admin": "Admin",
"components.UserList.created": "Erstellt",
"components.UserList.delete": "Löschen",
"components.UserList.edit": "Bearbeiten",
"components.UserList.lastupdated": "Zuletzt aktualisiert",
"components.UserList.plexuser": "Plex-Benutzer/in",
"components.UserList.plexuser": "Plex-Benutzer",
"components.UserList.role": "Rolle",
"components.UserList.totalrequests": "Anträge insgesamt",
"components.UserList.user": "Benutzer/in",
"components.UserList.userlist": "Benutzer/innen-Liste",
"components.UserList.totalrequests": "Anfragen insgesamt",
"components.UserList.user": "Benutzer",
"components.UserList.userlist": "Benutzer-Liste",
"components.UserList.username": "Benutzername",
"components.UserList.usertype": "Benutzertyp",
"i18n.approve": "Genehmigen",
@ -291,10 +291,10 @@
"i18n.decline": "Ablehnen",
"i18n.declined": "Abgelehnt",
"i18n.delete": "Löschen",
"i18n.movies": "Films",
"i18n.movies": "Filme",
"i18n.partiallyavailable": "Teilweise verfügbar",
"i18n.pending": "Ausstehend",
"i18n.processing": "Verarbeitung …",
"i18n.processing": "Verarbeiten …",
"i18n.tvshows": "Serien",
"i18n.unavailable": "Nicht verfügbar",
"pages.internalServerError": "{statusCode} Interner Serverfehler",
@ -302,62 +302,62 @@
"pages.pageNotFound": "404 Seite nicht gefunden",
"pages.returnHome": "Zur Startseite",
"pages.serviceUnavailable": "{statusCode} Dienst nicht verfügbar",
"pages.somethingWentWrong": "{statusCode} Es ist ein Fehler aufgetreten",
"components.TvDetails.TvCast.fullseriescast": "Vollserienbesetzung",
"components.MovieDetails.MovieCast.fullcast": "Vollständige Besetzung",
"pages.somethingWentWrong": "{statusCode} Es ist etwas schief gelaufen",
"components.TvDetails.TvCast.fullseriescast": "Komplette Serien Besetzung",
"components.MovieDetails.MovieCast.fullcast": "Komplette Besetzung",
"components.Settings.Notifications.emailsettingssaved": "E-Mail-Benachrichtigungseinstellungen gespeichert!",
"components.Settings.Notifications.emailsettingsfailed": "Fehler beim Speichern der E-Mail-Benachrichtigungseinstellungen.",
"components.Settings.Notifications.discordsettingssaved": "Discord-Benachrichtigungseinstellungen gespeichert!",
"components.Settings.Notifications.discordsettingsfailed": "Fehler beim Speichern der Discord-Benachrichtigungseinstellungen.",
"components.Settings.validationPortRequired": "Sie müssen einen Port angeben",
"components.Settings.validationHostnameRequired": "Sie müssen einen Hostnamen/IP angeben",
"components.Settings.SonarrModal.validationNameRequired": "Sie müssen einen Servernamen angeben",
"components.Settings.validationPortRequired": "Du musst einen Port angeben",
"components.Settings.validationHostnameRequired": "Du musst einen Hostnamen/IP angeben",
"components.Settings.SonarrModal.validationNameRequired": "Du musst einen Servernamen angeben",
"components.Settings.SettingsAbout.version": "Version",
"components.Settings.SettingsAbout.totalrequests": "Anträge insgesamt",
"components.Settings.SettingsAbout.totalrequests": "Anfragen insgesamt",
"components.Settings.SettingsAbout.totalmedia": "Medien insgesamt",
"components.Settings.SettingsAbout.overseerrinformation": "Overseerr-Informationen",
"components.Settings.SettingsAbout.githubdiscussions": "GitHub-Diskussionen",
"components.Settings.SettingsAbout.gettingsupport": "Hilfe erhalten",
"components.Settings.SettingsAbout.clickheretojoindiscord": "Klicken Sie hier, um unserem Discord-Server beizutreten.",
"components.Settings.RadarrModal.validationNameRequired": "Sie müssen einen Servernamen angeben",
"components.Settings.SettingsAbout.clickheretojoindiscord": "Klicke hier, um unserem Discord-Server beizutreten.",
"components.Settings.RadarrModal.validationNameRequired": "Du musst einen Servernamen angeben",
"components.Setup.tip": "Tipp",
"components.Setup.syncingbackground": "Die Synchronisierung wird im Hintergrund ausgeführt. Sie können die Konfiguration in der Zwischenzeit fortsetzen.",
"components.Setup.syncingbackground": "Die Synchronisierung wird im Hintergrund ausgeführt. Du kannst die Konfiguration in der Zwischenzeit fortsetzen.",
"i18n.deleting": "Löschen …",
"components.UserList.userdeleteerror": "Beim Löschen des Benutzers ist ein Fehler aufgetreten",
"components.UserList.userdeleteerror": "Beim Löschen des Benutzers ist etwas schief gelaufen",
"components.UserList.userdeleted": "Benutzer gelöscht",
"components.UserList.deleteuser": "Benutzer löschen",
"components.UserList.deleteconfirm": "Wollen Sie diesen Benutzer wirklich löschen? Alle vorhandenen Anforderungsdaten dieses Benutzers werden entfernt.",
"components.Settings.nodefaultdescription": "Mindestens ein Server muss als Standard markiert sein, bevor Anforderungen an Ihre Dienste weitergeleitet werden.",
"components.UserList.deleteconfirm": "Willst du diesen Benutzer wirklich löschen? Alle vorhandenen Anfragendaten dieses Benutzers werden entfernt.",
"components.Settings.nodefaultdescription": "Mindestens ein Server muss als Standard markiert sein, bevor Anfragen an deine Dienste weitergeleitet werden.",
"components.Settings.nodefault": "Kein Standardserver ausgewählt!",
"components.Settings.no4kimplemented": "(Standard-4K-Server sind derzeit nicht implementiert.)",
"components.Settings.SonarrModal.testFirstRootFolders": "Testen Sie Ihre Verbindung, um Stammordner zu laden",
"components.Settings.SonarrModal.testFirstQualityProfiles": "Testen Sie Ihre Verbindung, um Qualitätsprofile zu laden",
"components.Settings.SonarrModal.loadingrootfolders": "Laden von Stammordnern…",
"components.Settings.SonarrModal.loadingprofiles": "Qualitätsprofile werden geladen…",
"components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "Sie müssen die Mindestverfügbarkeit auswählen",
"components.Settings.RadarrModal.testFirstRootFolders": "Testen Sie Ihre Verbindung, um Stammordner zu laden",
"components.Settings.RadarrModal.testFirstQualityProfiles": "Testen Sie Ihre Verbindung zu Lastqualitätsprofilen",
"components.Settings.RadarrModal.loadingrootfolders": "Laden von Stammordnern…",
"components.Settings.RadarrModal.loadingprofiles": "Qualitätsprofile werden geladen…",
"components.Settings.SonarrModal.testFirstRootFolders": "Teste die Verbindung, um Stammordner zu laden",
"components.Settings.SonarrModal.testFirstQualityProfiles": "Teste die Verbindung, um Qualitätsprofile zu laden",
"components.Settings.SonarrModal.loadingrootfolders": "Stammordner werden geladen …",
"components.Settings.SonarrModal.loadingprofiles": "Qualitätsprofile werden geladen …",
"components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "Du musst die Mindestverfügbarkeit auswählen",
"components.Settings.RadarrModal.testFirstRootFolders": "Teste die Verbindung, um Stammordner zu laden",
"components.Settings.RadarrModal.testFirstQualityProfiles": "Teste die Verbindung, um Qualitätsprofile zu laden",
"components.Settings.RadarrModal.loadingrootfolders": "Stammordner werden geladen …",
"components.Settings.RadarrModal.loadingprofiles": "Qualitätsprofile werden geladen …",
"components.TvDetails.anime": "Anime",
"components.Settings.toastApiKeySuccess": "Neuer API-Schlüssel generiert!",
"components.TvDetails.showtype": "Serientyp",
"components.TvDetails.network": "Netzwerk",
"components.Settings.toastSettingsSuccess": "Einstellungen gespeichert.",
"components.Settings.toastSettingsFailure": "Beim Speichern der Einstellungen ging etwas schief.",
"components.Settings.toastApiKeyFailure": "Bei der Generierung eines neuen API-Schlüssels kam es zu einem Fehler.",
"components.Settings.toastSettingsFailure": "Beim Speichern der Einstellungen ist etwas schief gelaufen.",
"components.Settings.toastApiKeyFailure": "Bei der Generierung eines neuen API-Schlüssels ist etwas schief gelaufen.",
"components.Settings.SonarrModal.animerootfolder": "Animestammverzeichnis",
"components.Settings.SonarrModal.animequalityprofile": "Animequalitätsprofil",
"components.MovieDetails.studio": "Studio",
"i18n.close": "Schließen",
"components.Settings.SettingsAbout.timezone": "Zeitzone",
"components.Settings.SettingsAbout.supportoverseerr": "Unterstützen Sie Overseerr",
"components.Settings.SettingsAbout.helppaycoffee": "Helfen Sie uns, für Kaffee zu bezahlen",
"components.Settings.SettingsAbout.Releases.viewongithub": "Ansicht auf GitHub",
"components.Settings.SettingsAbout.supportoverseerr": "Unterstütze Overseerr",
"components.Settings.SettingsAbout.helppaycoffee": "Hilf uns Kaffee zu bezahlen",
"components.Settings.SettingsAbout.Releases.viewongithub": "Auf GitHub anzeigen",
"components.Settings.SettingsAbout.Releases.viewchangelog": "Änderungsprotokoll anzeigen",
"components.Settings.SettingsAbout.Releases.versionChangelog": "Änderungsprotokoll",
"components.Settings.SettingsAbout.Releases.runningDevelopMessage": "Die Änderungen in Ihrer Version sind unten nicht verfügbar. Die neuesten Aktualisierungen finden Sie im <GithubLink>GitHub-Repository</GithubLink>.",
"components.Settings.SettingsAbout.Releases.runningDevelop": "Sie führen eine Entwicklungsversion von Overseerr aus!",
"components.Settings.SettingsAbout.Releases.runningDevelopMessage": "Die Änderungen in deiner Version sind unten nicht verfügbar. Die neuesten Aktualisierungen findest du im <GithubLink>GitHub-Repository</GithubLink>.",
"components.Settings.SettingsAbout.Releases.runningDevelop": "Du führst eine Entwicklungsversion von Overseerr aus!",
"components.Settings.SettingsAbout.Releases.releases": "Veröffentlichungen",
"components.Settings.SettingsAbout.Releases.releasedataMissing": "Informationen der Version nicht verfügbar. Ist GitHub offline?",
"components.Settings.SettingsAbout.Releases.latestversion": "Neuste Version",
@ -365,15 +365,31 @@
"components.Settings.Notifications.testsent": "Testbenachrichtigung gesendet!",
"components.Settings.Notifications.test": "Test",
"components.Settings.defaultPermissions": "Standardbenutzerberechtigungen",
"components.UserList.importfromplexerror": "Beim Importieren von Benutzern aus Plex ist ein Fehler aufgetreten",
"components.UserList.importfromplex": "Benutzer/innen aus Plex importieren",
"components.TvDetails.viewfullcrew": "Volles Team anzeigen",
"components.TvDetails.TvCrew.fullseriescrew": "Volles Serienteam",
"components.UserList.importfromplexerror": "Beim Importieren von Benutzern aus Plex ist etwas schief gelaufen",
"components.UserList.importfromplex": "Benutzer aus Plex importieren",
"components.TvDetails.viewfullcrew": "Komplettes Team anzeigen",
"components.TvDetails.TvCrew.fullseriescrew": "Komplettes Serien Team",
"components.PersonDetails.crewmember": "Teammitglied",
"components.MovieDetails.viewfullcrew": "Volles Team anzeigen",
"components.MovieDetails.MovieCrew.fullcrew": "Volles Team",
"components.UserList.importedfromplex": "{userCount, plural, =0 {Keine neue Benutzer} one {# neuer Benutzer} other {# neue Benutzer}} aus Plex importiert",
"components.TvDetails.firstAirDate": "Erster Sendetermin",
"components.MovieDetails.viewfullcrew": "Komplettes Team anzeigen",
"components.MovieDetails.MovieCrew.fullcrew": "Komplettes Team",
"components.UserList.importedfromplex": "{userCount, plural, =0 {Keine neuen Benutzer} one {# neuer Benutzer} other {# neue Benutzer}} aus Plex importiert",
"components.TvDetails.firstAirDate": "Erstausstrahlung",
"components.Settings.Notifications.ssldisabletip": "SSL sollte bei Standard-TLS-Verbindungen deaktiviert werden (Port 587)",
"components.Settings.Notifications.allowselfsigned": "Selbstsignierte Zertifikate zulassen"
"components.Settings.Notifications.allowselfsigned": "Selbstsignierte Zertifikate zulassen",
"components.TvDetails.watchtrailer": "Trailer ansehen",
"components.MovieDetails.watchtrailer": "Trailer ansehen",
"components.MovieDetails.view": "Ansehen",
"components.CollectionDetails.requestswillbecreated": "Für die folgenden Titel werden Anfragen erstellt:",
"components.CollectionDetails.requesting": "Anfragen …",
"components.CollectionDetails.requestcollection": "Sammlung anfragen",
"components.CollectionDetails.requestSuccess": "<strong>{title}</strong> erfolgreich angefragt!",
"components.CollectionDetails.request": "Anfragen",
"components.CollectionDetails.overviewunavailable": "Übersicht nicht verfügbar",
"components.CollectionDetails.overview": "Übersicht",
"components.CollectionDetails.numberofmovies": "Anzahl der Filme: {count}",
"components.CollectionDetails.movies": "Filme",
"i18n.requested": "Angefragt",
"i18n.retry": "Wiederholen",
"i18n.failed": "Fehlgeschlagen",
"components.RequestList.RequestItem.failedretry": "Beim Wiederholen der Anfrage ist etwas schief gelaufen"
}

@ -1,4 +1,13 @@
{
"components.CollectionDetails.movies": "Movies",
"components.CollectionDetails.numberofmovies": "Number of Movies: {count}",
"components.CollectionDetails.overview": "Overview",
"components.CollectionDetails.overviewunavailable": "Overview unavailable",
"components.CollectionDetails.request": "Request",
"components.CollectionDetails.requestSuccess": "<strong>{title}</strong> successfully requested!",
"components.CollectionDetails.requestcollection": "Request Collection",
"components.CollectionDetails.requesting": "Requesting…",
"components.CollectionDetails.requestswillbecreated": "The following titles will have requests created for them:",
"components.Discover.discovermovies": "Popular Movies",
"components.Discover.discovertv": "Popular Series",
"components.Discover.nopending": "No Pending Requests",
@ -47,8 +56,10 @@
"components.MovieDetails.studio": "Studio",
"components.MovieDetails.unavailable": "Unavailable",
"components.MovieDetails.userrating": "User Rating",
"components.MovieDetails.view": "View",
"components.MovieDetails.viewfullcrew": "View Full Crew",
"components.MovieDetails.viewrequest": "View Request",
"components.MovieDetails.watchtrailer": "Watch Trailer",
"components.PersonDetails.appearsin": "Appears in",
"components.PersonDetails.ascharacter": "as {character}",
"components.PersonDetails.crewmember": "Crew Member",
@ -60,6 +71,7 @@
"components.RequestCard.all": "All",
"components.RequestCard.requestedby": "Requested by {username}",
"components.RequestCard.seasons": "Seasons",
"components.RequestList.RequestItem.failedretry": "Something went wrong retrying the request",
"components.RequestList.RequestItem.notavailable": "N/A",
"components.RequestList.RequestItem.requestedby": "Requested by {username}",
"components.RequestList.RequestItem.seasons": "Seasons",
@ -92,6 +104,18 @@
"components.RequestModal.selectseason": "Select season(s)",
"components.RequestModal.status": "Status",
"components.Search.searchresults": "Search Results",
"components.Settings.Notifications.NotificationsSlack.agentenabled": "Agent Enabled",
"components.Settings.Notifications.NotificationsSlack.save": "Save Changes",
"components.Settings.Notifications.NotificationsSlack.saving": "Saving...",
"components.Settings.Notifications.NotificationsSlack.settingupslack": "Setting up Slack Notifications",
"components.Settings.Notifications.NotificationsSlack.settingupslackDescription": "To use Slack notifications, you will need to create an <WebhookLink>Incoming Webhook</WebhookLink> integration and use the provided webhook URL below.",
"components.Settings.Notifications.NotificationsSlack.slacksettingsfailed": "Slack notification settings failed to save.",
"components.Settings.Notifications.NotificationsSlack.slacksettingssaved": "Slack notification settings saved!",
"components.Settings.Notifications.NotificationsSlack.test": "Test",
"components.Settings.Notifications.NotificationsSlack.testsent": "Test notification sent!",
"components.Settings.Notifications.NotificationsSlack.validationWebhookUrlRequired": "You must provide a webhook URL",
"components.Settings.Notifications.NotificationsSlack.webhookUrl": "Webhook URL",
"components.Settings.Notifications.NotificationsSlack.webhookUrlPlaceholder": "Webhook URL",
"components.Settings.Notifications.agentenabled": "Agent Enabled",
"components.Settings.Notifications.allowselfsigned": "Allow Self-Signed Certificates",
"components.Settings.Notifications.authPass": "Auth Pass",
@ -313,10 +337,15 @@
"components.TvDetails.unavailable": "Unavailable",
"components.TvDetails.userrating": "User Rating",
"components.TvDetails.viewfullcrew": "View Full Crew",
"components.TvDetails.watchtrailer": "Watch Trailer",
"components.UserEdit.admin": "Admin",
"components.UserEdit.adminDescription": "Full administrator access. Bypasses all permission checks.",
"components.UserEdit.autoapprove": "Auto Approve",
"components.UserEdit.autoapproveDescription": "Grants auto approval for any requests made by this user.",
"components.UserEdit.autoapproveMovies": "Auto Approve Movies",
"components.UserEdit.autoapproveMoviesDescription": "Grants auto approve for movie requests made by this user.",
"components.UserEdit.autoapproveSeries": "Auto Approve Series",
"components.UserEdit.autoapproveSeriesDescription": "Grants auto approve for series requests made by this user.",
"components.UserEdit.avatar": "Avatar",
"components.UserEdit.edituser": "Edit User",
"components.UserEdit.email": "Email",
@ -364,10 +393,13 @@
"i18n.declined": "Declined",
"i18n.delete": "Delete",
"i18n.deleting": "Deleting…",
"i18n.failed": "Failed",
"i18n.movies": "Movies",
"i18n.partiallyavailable": "Partially Available",
"i18n.pending": "Pending",
"i18n.processing": "Processing…",
"i18n.requested": "Requested",
"i18n.retry": "Retry",
"i18n.tvshows": "Series",
"i18n.unavailable": "Unavailable",
"pages.internalServerError": "{statusCode} - Internal Server Error",

@ -304,7 +304,7 @@
"components.Settings.menuJobs": "Tareas",
"components.Settings.menuGeneralSettings": "Ajustes Generales",
"components.Settings.menuAbout": "Acerca de",
"components.Settings.manualscanDescription": "Normalmente, esto sólo se ejecutará una vez cada 6 horas. Overseerr comprobará de forma más agresiva los añadidos recientemente de su servidor Plex. ¡Si es la primera vez que configura Plex se recomienda un escaneo manual completo de la biblioteca!",
"components.Settings.manualscanDescription": "Normalmente, esto sólo se ejecutará una vez cada 24 horas. Overseerr comprobará de forma más agresiva los añadidos recientemente de su servidor Plex. ¡Si es la primera vez que configura Plex se recomienda un escaneo manual completo de la biblioteca!",
"components.Settings.manualscan": "Escaneo manual de biblioteca",
"components.Settings.librariesRemaining": "Bibliotecas restantes: {count}",
"components.Settings.hostname": "Nombre de host / IP",
@ -364,5 +364,25 @@
"components.Settings.SettingsAbout.Releases.currentversion": "Versión Actual",
"components.Settings.Notifications.testsent": "¡Notificación de prueba enviada!",
"components.Settings.Notifications.test": "Comprobar",
"components.MovieDetails.studio": "Estudio"
"components.MovieDetails.studio": "Estudio",
"components.UserList.importfromplexerror": "Algo salió mal importando usuarios de Plex",
"components.UserList.importfromplex": "Importar usuarios de Plex",
"components.UserList.importedfromplex": "{userCount, plural, =0 {Ningún} one {# Nuevo usuario} other {# Nuevos usuarios}} importado/s de Plex",
"components.TvDetails.viewfullcrew": "Ver Equipo Completo",
"components.TvDetails.firstAirDate": "Primera fecha de emisión",
"components.TvDetails.TvCrew.fullseriescrew": "Equipo completo de la serie",
"components.Settings.Notifications.ssldisabletip": "SSL debe estar deshabilitado en conexiones TLS estándar (puerto 587)",
"components.Settings.Notifications.allowselfsigned": "Permitir certificados autofirmados",
"components.PersonDetails.crewmember": "Miembro del Equipo",
"components.MovieDetails.viewfullcrew": "Ver Equipo Completo",
"components.MovieDetails.MovieCrew.fullcrew": "Equipo Completo",
"components.CollectionDetails.requestswillbecreated": "Los siguientes títulos tendrán solicitudes creadas para ellos:",
"components.CollectionDetails.requesting": "Solicitando…",
"components.CollectionDetails.requestcollection": "Solicitar Colección",
"components.CollectionDetails.requestSuccess": "<strong> {title} </strong> solicitado correctamente!",
"components.CollectionDetails.request": "Solicitar",
"components.CollectionDetails.overviewunavailable": "Resumen no disponible",
"components.CollectionDetails.overview": "Resumen",
"components.CollectionDetails.numberofmovies": "Número de películas: {count}",
"components.CollectionDetails.movies": "Películas"
}

@ -375,5 +375,18 @@
"components.MovieDetails.MovieCrew.fullcrew": "Équipe complète",
"components.TvDetails.firstAirDate": "Date de première diffusion",
"components.Settings.Notifications.ssldisabletip": "Le SSL doit être désactivé sur les connexions TLS standard (Port 587)",
"components.Settings.Notifications.allowselfsigned": "Autoriser les certificats autosignés"
"components.Settings.Notifications.allowselfsigned": "Autoriser les certificats autosignés",
"components.TvDetails.watchtrailer": "Regarder la bande-annonce",
"components.MovieDetails.watchtrailer": "Regarder la bande-annonce",
"components.MovieDetails.view": "Voir",
"components.CollectionDetails.requestswillbecreated": "Des demandes seront créées pour les titres suivants :",
"components.CollectionDetails.requesting": "Demande en cours…",
"components.CollectionDetails.requestcollection": "Demander une collection",
"components.CollectionDetails.requestSuccess": "<strong>{title}</strong> demandé avec succès !",
"components.CollectionDetails.request": "Demander",
"components.CollectionDetails.overviewunavailable": "Résumé indisponible",
"components.CollectionDetails.overview": "Résumé",
"components.CollectionDetails.numberofmovies": "Nombre de films : {count}",
"components.CollectionDetails.movies": "Films",
"i18n.requested": "Demandé"
}

@ -108,7 +108,7 @@
"components.UserEdit.saving": "Salvataggio…",
"components.UserEdit.save": "Salva",
"components.UserEdit.usersaved": "Utente salvato",
"components.UserEdit.usersDescription": "Concede l'autorizzazione per gestire gli utenti di Overseerr. Gli utenti con questa autorizzazione non possono modificare gli utenti con privilegi di amministratore o concederla.",
"components.UserEdit.usersDescription": "Concede l'autorizzazione per gestire gli utenti di Overseerr. Gli utenti con questa autorizzazione non possono modificare gli utenti con privilegi di amministratore, né concederli.",
"components.UserEdit.users": "Gestisci gli utenti",
"components.UserEdit.username": "Nome utente",
"components.UserList.deleteconfirm": "Eliminare l'utente? Tutti i dati di richiesta esistenti da questo utente verranno rimossi.",
@ -293,14 +293,14 @@
"components.RequestModal.requestseasons": "Richiedi {seasonCount} {seasonCount, plural, one {Season} other {Seasons}}",
"components.RequestList.showingresults": "Visualizzazione dei risultati da <strong>{from}</strong> a <strong>{to}</strong> di <strong>{total}</strong>",
"components.UserEdit.userfail": "Qualcosa è andato storto salvando l'utente.",
"components.UserEdit.requestDescription": "Concede l'autorizzazione per richiedere film e serie.",
"components.UserEdit.requestDescription": "Concede il permesso di richiedere film e serie.",
"components.UserEdit.request": "Richiesta",
"components.UserEdit.permissions": "Autorizzazioni",
"components.UserEdit.managerequestsDescription": "Concede l'autorizzazione per gestire le richieste Overseerr. Ciò include l'approvazione e la negazione delle richieste.",
"components.UserEdit.managerequestsDescription": "Concede il permesso di gestire le richieste di Overseerr. Ciò include l'approvazione e la negazione delle richieste.",
"components.UserEdit.managerequests": "Gestisci le richieste",
"components.UserEdit.autoapproveDescription": "Concede l'approvazione automatica per tutte le richieste effettuate da questo utente.",
"components.UserEdit.autoapprove": "Approvazione automatica",
"components.UserEdit.adminDescription": "Accesso completo all'amministratore. Ignora tutti i controlli delle autorizzazioni.",
"components.UserEdit.adminDescription": "Accesso amministratore completo. Bypassa tutti gli altri permessi.",
"components.UserEdit.admin": "Amministratore",
"components.TvDetails.userrating": "Voto pubblico",
"components.TvDetails.unavailable": "Non disponibile",
@ -322,7 +322,7 @@
"components.Settings.jobname": "Nome Task",
"components.UserEdit.voteDescription": "Concede il permesso di votare sulle richieste (sistema di voto non acora implementato)",
"components.UserEdit.vote": "Vota",
"components.UserEdit.settingsDescription": "Concede l'autorizzazione a modificare tutte le impostazioni di Overseerr. Un utente deve disporre di questa autorizzazione per concederla ad altri.",
"components.UserEdit.settingsDescription": "Permette di modificare tutte le impostazioni di Overseerr. Un utente deve avere questa autorizzazione per poterla concedere ad altri.",
"components.TvDetails.manageModalClearMediaWarning": "Questo rimuoverà tutti i dati, incluse le richieste per questo elemento in modo irreversibile. Se questo elemento esiste nella tua libreria di Plex, i dati verranno ricreati alla prossima sincronizzazione.",
"components.Setup.syncingbackground": "La sincronizzazione verrà eseguita in background. Nel frattempo puoi continuare il processo di configurazione.",
"components.Setup.signinMessage": "Comincia accedendo con il tuo account Plex",
@ -364,7 +364,7 @@
"components.Settings.SettingsAbout.Releases.currentversion": "Versione attuale",
"components.Settings.Notifications.testsent": "Notifica di prova inviata!",
"components.Settings.Notifications.test": "Test",
"components.Settings.defaultPermissions": "Autorizzazioni utente predefinite",
"components.Settings.defaultPermissions": "Autorizzazioni predefinite per nuovi utenti",
"components.UserList.importfromplexerror": "Qualcosa è andato storto durante l'importazione degli utenti da Plex",
"components.UserList.importfromplex": "Importa utenti da Plex",
"components.UserList.importedfromplex": "{userCount, plural, =0 {Nessun utente importato} one {# nuovo utente importato} other {# nuovi utenti importati}} da Plex",
@ -374,5 +374,19 @@
"components.MovieDetails.MovieCrew.fullcrew": "Troupe completa",
"components.MovieDetails.viewfullcrew": "Vedi troupe completa",
"components.Settings.Notifications.ssldisabletip": "SSL dovrebbe essere disabilitato sulle connessioni standard TLS (Porta 587)",
"components.Settings.Notifications.allowselfsigned": "Consenti i certificati autofirmati"
"components.Settings.Notifications.allowselfsigned": "Consenti i certificati autofirmati",
"components.TvDetails.firstAirDate": "Prima data di messa in onda",
"components.CollectionDetails.requestswillbecreated": "Per i titoli seguenti verranno create richieste:",
"components.CollectionDetails.requesting": "Richiesta in corso…",
"components.CollectionDetails.requestcollection": "Richiedi raccolta",
"components.CollectionDetails.requestSuccess": "<strong>{titolo}</strong> richiesto con successo!",
"components.CollectionDetails.request": "Richiedi",
"components.CollectionDetails.overviewunavailable": "Trama non disponibile",
"components.CollectionDetails.overview": "Trama",
"components.CollectionDetails.numberofmovies": "Numero di film: {count}",
"components.CollectionDetails.movies": "Film",
"components.TvDetails.watchtrailer": "Guarda il trailer",
"components.MovieDetails.watchtrailer": "Guarda il trailer",
"components.MovieDetails.view": "Visualizza",
"i18n.requested": "Richiesto"
}

@ -183,7 +183,7 @@
"components.Settings.jobname": "ジョブ名",
"components.Settings.librariesRemaining": "残りのライブラリー: {count}",
"components.Settings.manualscan": "手動ライブラリースキャン",
"components.Settings.manualscanDescription": "通常は6時間に一度しか実行されません。Overseerr は、Plex サーバーの最近追加されたフォルダをより頻繁にチェックします。初めて Plex を設定する場合は、一度手動でライブラリーをスキャンすることをお勧めします。",
"components.Settings.manualscanDescription": "通常は24時間に一度しか実行されません。Overseerr は、Plex サーバーの最近追加されたフォルダをより頻繁にチェックします。初めて Plex を設定する場合は、一度手動でライブラリーをスキャンすることをお勧めします。",
"components.Settings.menuAbout": "Overseerrについて",
"components.Settings.menuGeneralSettings": "一般設定",
"components.Settings.menuJobs": "ジョブ",
@ -365,5 +365,28 @@
"components.Settings.SettingsAbout.Releases.latestversion": "最新バージョン",
"components.Settings.SettingsAbout.Releases.currentversion": "現在のバージョン",
"components.Settings.Notifications.testsent": "テスト通知が送信されました。",
"components.MovieDetails.MovieCrew.fullcrew": "フルクルー"
"components.MovieDetails.MovieCrew.fullcrew": "フルクルー",
"components.MovieDetails.viewfullcrew": "フルクルーを表示",
"components.CollectionDetails.requestswillbecreated": "以下のタイトルをリクエストします。",
"components.CollectionDetails.requesting": "リクエスト中…",
"components.CollectionDetails.requestcollection": "リクエストコレクション",
"components.CollectionDetails.requestSuccess": "<strong>{title}</strong> をリクエストしました!",
"components.CollectionDetails.request": "リクエスト",
"components.CollectionDetails.overviewunavailable": "ストーリー情報ありません",
"components.CollectionDetails.overview": "ストーリー",
"components.CollectionDetails.numberofmovies": "作品数: {count}",
"components.CollectionDetails.movies": "映画",
"i18n.requested": "リクエスト済み",
"components.TvDetails.watchtrailer": "予告編を見る",
"components.MovieDetails.watchtrailer": "予告編を見る",
"components.MovieDetails.view": "表示",
"components.UserList.importfromplexerror": "Plexからユーザーをインポート中に問題が発生しました",
"components.UserList.importfromplex": "Plexからユーザーをインポート",
"components.UserList.importedfromplex": "Plexから{userCount, plural, =0 {新ユーザーはインポートされませんでした。} one {新ユーザー #名をインポートしました。} other {新ユーザー #名をインポートしました。}}",
"components.TvDetails.viewfullcrew": "フルクルーを表示",
"components.TvDetails.firstAirDate": "初放送日",
"components.TvDetails.TvCrew.fullseriescrew": "フルシリーズクルー",
"components.Settings.Notifications.ssldisabletip": "標準TLS接続ポート587ではSSLを無効にすることをおすすめします。",
"components.Settings.Notifications.allowselfsigned": "自己署名証明書を許可する",
"components.PersonDetails.crewmember": "クルーメンバー"
}

@ -0,0 +1,38 @@
import React from 'react';
import { GetServerSideProps, NextPage } from 'next';
import type { Collection } from '../../../../server/models/Collection';
import axios from 'axios';
import { parseCookies } from 'nookies';
import CollectionDetails from '../../../components/CollectionDetails';
interface CollectionPageProps {
collection?: Collection;
}
const CollectionPage: NextPage<CollectionPageProps> = ({ collection }) => {
return <CollectionDetails collection={collection} />;
};
export const getServerSideProps: GetServerSideProps<CollectionPageProps> = async (
ctx
) => {
const cookies = parseCookies(ctx);
const response = await axios.get<Collection>(
`http://localhost:${process.env.PORT || 5055}/api/v1/collection/${
ctx.query.collectionId
}${cookies.locale ? `?language=${cookies.locale}` : ''}`,
{
headers: ctx.req?.headers?.cookie
? { cookie: ctx.req.headers.cookie }
: undefined,
}
);
return {
props: {
collection: response.data,
},
};
};
export default CollectionPage;

@ -0,0 +1,17 @@
import { NextPage } from 'next';
import React from 'react';
import NotificationsSlack from '../../../components/Settings/Notifications/NotificationsSlack';
import SettingsLayout from '../../../components/Settings/SettingsLayout';
import SettingsNotifications from '../../../components/Settings/SettingsNotifications';
const NotificationsSlackPage: NextPage = () => {
return (
<SettingsLayout>
<SettingsNotifications>
<NotificationsSlack />
</SettingsNotifications>
</SettingsLayout>
);
};
export default NotificationsSlackPage;

@ -6114,6 +6114,13 @@ file-entry-cache@^6.0.0:
dependencies:
flat-cache "^3.0.4"
file-stream-rotator@^0.5.7:
version "0.5.7"
resolved "https://registry.yarnpkg.com/file-stream-rotator/-/file-stream-rotator-0.5.7.tgz#868a2e5966f7640a17dd86eda0e4467c089f6286"
integrity sha512-VYb3HZ/GiAGUCrfeakO8Mp54YGswNUHvL7P09WQcXAJNSj3iQ5QraYSp3cIn1MUyw6uzfgN/EFOarCNa4JvUHQ==
dependencies:
moment "^2.11.2"
file-uri-to-path@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
@ -9050,7 +9057,7 @@ moment-timezone@^0.5.31:
dependencies:
moment ">= 2.9.0"
"moment@>= 2.9.0":
"moment@>= 2.9.0", moment@^2.11.2:
version "2.29.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
@ -9828,7 +9835,7 @@ object-copy@^0.1.0:
define-property "^0.2.5"
kind-of "^3.0.3"
object-hash@^2.0.3:
object-hash@^2.0.1, object-hash@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.0.3.tgz#d12db044e03cd2ca3d77c0570d87225b02e1e6ea"
integrity sha512-JPKn0GMu+Fa3zt3Bmr66JhokJU5BaNBIh4ZeTlaCBzrBsOeXzwcKKAK1tbLiPKgvwmPXsDvvLHoWh5Bm7ofIYg==
@ -14157,7 +14164,17 @@ widest-line@^3.1.0:
dependencies:
string-width "^4.0.0"
winston-transport@^4.4.0:
winston-daily-rotate-file@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/winston-daily-rotate-file/-/winston-daily-rotate-file-4.5.0.tgz#3914ac57c4bdae1138170bec85af0c2217b253b1"
integrity sha512-/HqeWiU48dzGqcrABRlxYWVMdL6l3uKCtFSJyrqK+E2rLnSFNsgYpvwx15EgTitBLNzH69lQd/+z2ASryV2aqw==
dependencies:
file-stream-rotator "^0.5.7"
object-hash "^2.0.1"
triple-beam "^1.3.0"
winston-transport "^4.2.0"
winston-transport@^4.2.0, winston-transport@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.4.0.tgz#17af518daa690d5b2ecccaa7acf7b20ca7925e59"
integrity sha512-Lc7/p3GtqtqPBYYtS6KCN3c77/2QCev51DvcJKbkFPQNoj1sinkGwLGFDxkXY9J6p9+EPnYs+D90uwbnaiURTw==

Loading…
Cancel
Save