Merge branch 'develop'

pull/570/head
sct 4 years ago
commit f99ab47c01

@ -216,6 +216,25 @@
"contributions": [ "contributions": [
"code" "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>", "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 # logs
config/logs/*.log* config/logs/*.log*
config/logs/*.json
# dist files # dist files
dist dist

@ -14,16 +14,46 @@ All help is welcome and greatly appreciated. If you would like to contribute to
### Getting Started ### 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. 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`. git remote add upstream https://github.com/sct/overseerr.git
- Bad examples would be `patch` or `bug`. ```
3. Install dependencies `yarn` 3. Create a new branch
4. `yarn dev` to build and watch for changes
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 ### 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> <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"> <img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr">
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section --> <!-- 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 --> <!-- ALL-CONTRIBUTORS-BADGE:END -->
</p> </p>
@ -128,6 +128,8 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<tr> <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/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/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> </tr>
</table> </table>

@ -383,6 +383,36 @@ components:
type: string type: string
name: name:
type: string 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: MovieDetails:
type: object type: object
properties: properties:
@ -408,6 +438,10 @@ components:
$ref: '#/components/schemas/Genre' $ref: '#/components/schemas/Genre'
homepage: homepage:
type: string type: string
relatedVideos:
type: array
items:
$ref: '#/components/schemas/RelatedVideo'
originalLanguage: originalLanguage:
type: string type: string
originalTitle: originalTitle:
@ -463,6 +497,19 @@ components:
type: array type: array
items: items:
$ref: '#/components/schemas/Crew' $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: externalIds:
$ref: '#/components/schemas/ExternalIds' $ref: '#/components/schemas/ExternalIds'
mediaInfo: mediaInfo:
@ -794,6 +841,20 @@ components:
properties: properties:
webhookUrl: webhookUrl:
type: string type: string
SlackSettings:
type: object
properties:
enabled:
type: boolean
example: false
types:
type: number
example: 2
options:
type: object
properties:
webhookUrl:
type: string
NotificationEmailSettings: NotificationEmailSettings:
type: object type: object
properties: properties:
@ -991,6 +1052,26 @@ components:
name: name:
type: string type: string
example: 'English' 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: securitySchemes:
cookieAuth: cookieAuth:
type: apiKey type: apiKey
@ -1554,6 +1635,52 @@ paths:
responses: responses:
'204': '204':
description: Test notification attempted 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: /settings/about:
get: get:
summary: Return current about stats summary: Return current about stats
@ -1691,7 +1818,8 @@ paths:
application/json: application/json:
schema: schema:
type: array type: array
$ref: '#/components/schemas/User' items:
$ref: '#/components/schemas/User'
/user/{userId}: /user/{userId}:
get: get:
@ -2132,6 +2260,30 @@ paths:
responses: responses:
'204': '204':
description: Succesfully removed request 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}: /request/{requestId}/{status}:
get: get:
summary: Update a requests status summary: Update a requests status
@ -2626,6 +2778,31 @@ paths:
responses: responses:
'204': '204':
description: Succesfully removed media item 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: security:
- cookieAuth: [] - cookieAuth: []

@ -53,6 +53,7 @@
"typeorm": "^0.2.29", "typeorm": "^0.2.29",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"winston": "^3.3.3", "winston": "^3.3.3",
"winston-daily-rotate-file": "^4.5.0",
"xml2js": "^0.4.23", "xml2js": "^0.4.23",
"yamljs": "^0.3.0", "yamljs": "^0.3.0",
"yup": "^0.32.8" "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" "type": "image/png"
} }
], ],
"theme_color": "#ffffff", "theme_color": "#2d3748",
"background_color": "#1e2937", "background_color": "#2d3748",
"display": "standalone" "display": "standalone"
} }

@ -76,7 +76,7 @@ class RadarrAPI {
} }
}; };
public addMovie = async (options: RadarrMovieOptions): Promise<void> => { public addMovie = async (options: RadarrMovieOptions): Promise<boolean> => {
try { try {
const response = await this.axios.post<RadarrMovie>(`/movie`, { const response = await this.axios.post<RadarrMovie>(`/movie`, {
title: options.title, title: options.title,
@ -104,7 +104,9 @@ class RadarrAPI {
label: 'Radarr', label: 'Radarr',
options, options,
}); });
return false;
} }
return true;
} catch (e) { } catch (e) {
logger.error( 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.', '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', label: 'Radarr',
errorMessage: e.message, errorMessage: e.message,
options, 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 { try {
const series = await this.getSeriesByTvdbId(options.tvdbid); const series = await this.getSeriesByTvdbId(options.tvdbid);
@ -147,9 +147,10 @@ class SonarrAPI {
label: 'Sonarr', label: 'Sonarr',
options, options,
}); });
return false;
} }
return newSeriesResponse.data; return true;
} }
const createdSeriesResponse = await this.axios.post<SonarrSeries>( const createdSeriesResponse = await this.axios.post<SonarrSeries>(
@ -188,16 +189,18 @@ class SonarrAPI {
label: 'Sonarr', label: 'Sonarr',
options, options,
}); });
return false;
} }
return createdSeriesResponse.data; return true;
} catch (e) { } catch (e) {
logger.error('Something went wrong adding a series to Sonarr', { logger.error('Something went wrong adding a series to Sonarr', {
label: 'Sonarr API', label: 'Sonarr API',
errorMessage: e.message, errorMessage: e.message,
error: e, error: e,
response: e?.response?.data,
}); });
throw new Error('Failed to add series'); return false;
} }
} }

@ -190,7 +190,30 @@ export interface TmdbMovieDetails {
cast: TmdbCreditCast[]; cast: TmdbCreditCast[];
crew: TmdbCreditCrew[]; crew: TmdbCreditCrew[];
}; };
belongs_to_collection?: {
id: number;
name: string;
poster_path?: string;
backdrop_path?: string;
};
external_ids: TmdbExternalIds; 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 { export interface TmdbTvEpisodeResult {
@ -278,6 +301,11 @@ export interface TmdbTvDetails {
keywords: { keywords: {
results: TmdbKeyword[]; results: TmdbKeyword[];
}; };
videos: TmdbVideoResult;
}
export interface TmdbVideoResult {
results: TmdbVideo[];
} }
export interface TmdbKeyword { export interface TmdbKeyword {
@ -344,6 +372,15 @@ export interface TmdbSeasonWithEpisodes extends TmdbTvSeasonResult {
external_ids: TmdbExternalIds; external_ids: TmdbExternalIds;
} }
export interface TmdbCollection {
id: number;
name: string;
overview?: string;
poster_path?: string;
backdrop_path?: string;
parts: TmdbMovieResult[];
}
class TheMovieDb { class TheMovieDb {
private apiKey = 'db55323b8d3e4154498498a75642b381'; private apiKey = 'db55323b8d3e4154498498a75642b381';
private axios: AxiosInstance; private axios: AxiosInstance;
@ -438,7 +475,10 @@ class TheMovieDb {
const response = await this.axios.get<TmdbMovieDetails>( const response = await this.axios.get<TmdbMovieDetails>(
`/movie/${movieId}`, `/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}`, { const response = await this.axios.get<TmdbTvDetails>(`/tv/${tvId}`, {
params: { params: {
language, 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; export default TheMovieDb;

@ -69,6 +69,12 @@ export class MediaRequest {
Object.assign(this, init); Object.assign(this, init);
} }
@AfterUpdate()
@AfterInsert()
public async sendMedia(): Promise<void> {
await Promise.all([this._sendToRadarr(), this._sendToSonarr()]);
}
@AfterInsert() @AfterInsert()
private async _notifyNewRequest() { private async _notifyNewRequest() {
if (this.status === MediaRequestStatus.PENDING) { if (this.status === MediaRequestStatus.PENDING) {
@ -163,7 +169,7 @@ export class MediaRequest {
@AfterUpdate() @AfterUpdate()
@AfterInsert() @AfterInsert()
private async _updateParentStatus() { public async updateParentStatus(): Promise<void> {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
const media = await mediaRepository.findOne({ const media = await mediaRepository.findOne({
where: { id: this.media.id }, where: { id: this.media.id },
@ -229,14 +235,13 @@ export class MediaRequest {
} }
} }
@AfterUpdate()
@AfterInsert()
private async _sendToRadarr() { private async _sendToRadarr() {
if ( if (
this.status === MediaRequestStatus.APPROVED && this.status === MediaRequestStatus.APPROVED &&
this.type === MediaType.MOVIE this.type === MediaType.MOVIE
) { ) {
try { try {
const mediaRepository = getRepository(Media);
const settings = getSettings(); const settings = getSettings();
if (settings.radarr.length === 0 && !settings.radarr[0]) { if (settings.radarr.length === 0 && !settings.radarr[0]) {
logger.info( logger.info(
@ -268,17 +273,49 @@ export class MediaRequest {
const movie = await tmdb.getMovie({ movieId: this.media.tmdbId }); const movie = await tmdb.getMovie({ movieId: this.media.tmdbId });
// Run this asynchronously so we don't wait for it on the UI side // Run this asynchronously so we don't wait for it on the UI side
radarr.addMovie({ radarr
profileId: radarrSettings.activeProfileId, .addMovie({
qualityProfileId: radarrSettings.activeProfileId, profileId: radarrSettings.activeProfileId,
rootFolderPath: radarrSettings.activeDirectory, qualityProfileId: radarrSettings.activeProfileId,
minimumAvailability: radarrSettings.minimumAvailability, rootFolderPath: radarrSettings.activeDirectory,
title: movie.title, minimumAvailability: radarrSettings.minimumAvailability,
tmdbId: movie.id, title: movie.title,
year: Number(movie.release_date.slice(0, 4)), tmdbId: movie.id,
monitored: true, year: Number(movie.release_date.slice(0, 4)),
searchNow: true, 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' }); logger.info('Sent request to Radarr', { label: 'Media Request' });
} catch (e) { } catch (e) {
throw new Error( throw new Error(
@ -288,8 +325,6 @@ export class MediaRequest {
} }
} }
@AfterUpdate()
@AfterInsert()
private async _sendToSonarr() { private async _sendToSonarr() {
if ( if (
this.status === MediaRequestStatus.APPROVED && 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 // Run this asynchronously so we don't wait for it on the UI side
sonarr.addSeries({ sonarr
profileId: .addSeries({
seriesType === 'anime' && sonarrSettings.activeAnimeProfileId profileId:
? sonarrSettings.activeAnimeProfileId seriesType === 'anime' && sonarrSettings.activeAnimeProfileId
: sonarrSettings.activeProfileId, ? sonarrSettings.activeAnimeProfileId
rootFolderPath: : sonarrSettings.activeProfileId,
seriesType === 'anime' && sonarrSettings.activeAnimeDirectory rootFolderPath:
? sonarrSettings.activeAnimeDirectory seriesType === 'anime' && sonarrSettings.activeAnimeDirectory
: sonarrSettings.activeDirectory, ? sonarrSettings.activeAnimeDirectory
title: series.name, : sonarrSettings.activeDirectory,
tvdbid: series.external_ids.tvdb_id, title: series.name,
seasons: this.seasons.map((season) => season.seasonNumber), tvdbid: series.external_ids.tvdb_id,
seasonFolder: sonarrSettings.enableSeasonFolders, seasons: this.seasons.map((season) => season.seasonNumber),
seriesType, seasonFolder: sonarrSettings.enableSeasonFolders,
monitored: true, seriesType,
searchNow: true, 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' }); logger.info('Sent request to Sonarr', { label: 'Media Request' });
} catch (e) { } catch (e) {
throw new Error( throw new Error(

@ -18,6 +18,7 @@ import notificationManager from './lib/notifications';
import DiscordAgent from './lib/notifications/agents/discord'; import DiscordAgent from './lib/notifications/agents/discord';
import EmailAgent from './lib/notifications/agents/email'; import EmailAgent from './lib/notifications/agents/email';
import { getAppVersion } from './utils/appVersion'; import { getAppVersion } from './utils/appVersion';
import SlackAgent from './lib/notifications/agents/slack';
const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml');
@ -42,7 +43,11 @@ app
const settings = getSettings().load(); const settings = getSettings().load();
// Register Notification Agents // Register Notification Agents
notificationManager.registerAgents([new DiscordAgent(), new EmailAgent()]); notificationManager.registerAgents([
new DiscordAgent(),
new EmailAgent(),
new SlackAgent(),
]);
// Start Jobs // Start Jobs
startJobs(); 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) { if (settings.main.applicationUrl) {
fields.push({ fields.push({
name: 'View Media', 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) { private async sendMediaApprovedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app // This is getting main settings for the whole app
const applicationUrl = getSettings().main.applicationUrl; const applicationUrl = getSettings().main.applicationUrl;
@ -228,6 +274,9 @@ class EmailAgent
case Notification.MEDIA_AVAILABLE: case Notification.MEDIA_AVAILABLE:
this.sendMediaAvailableEmail(payload); this.sendMediaAvailableEmail(payload);
break; break;
case Notification.MEDIA_FAILED:
this.sendMediaFailedEmail(payload);
break;
case Notification.TEST_NOTIFICATION: case Notification.TEST_NOTIFICATION:
this.sendTestEmail(payload); this.sendTestEmail(payload);
break; 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_PENDING = 2,
MEDIA_APPROVED = 4, MEDIA_APPROVED = 4,
MEDIA_AVAILABLE = 8, MEDIA_AVAILABLE = 8,
TEST_NOTIFICATION = 16, MEDIA_FAILED = 16,
TEST_NOTIFICATION = 32,
} }
class NotificationManager { class NotificationManager {

@ -7,6 +7,8 @@ export enum Permission {
REQUEST = 32, REQUEST = 32,
VOTE = 64, VOTE = 64,
AUTO_APPROVE = 128, 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 { export interface NotificationAgentEmail extends NotificationAgentConfig {
options: { options: {
emailFrom: string; emailFrom: string;
@ -81,6 +87,7 @@ export interface NotificationAgentEmail extends NotificationAgentConfig {
interface NotificationAgents { interface NotificationAgents {
email: NotificationAgentEmail; email: NotificationAgentEmail;
discord: NotificationAgentDiscord; discord: NotificationAgentDiscord;
slack: NotificationAgentSlack;
} }
interface NotificationSettings { interface NotificationSettings {
@ -142,6 +149,13 @@ class Settings {
webhookUrl: '', webhookUrl: '',
}, },
}, },
slack: {
enabled: false,
types: 0,
options: {
webhookUrl: '',
},
},
}, },
}, },
}; };

@ -1,5 +1,17 @@
import * as winston from 'winston'; import * as winston from 'winston';
import 'winston-daily-rotate-file';
import path from 'path'; 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( const hformat = winston.format.printf(
({ level, label, message, timestamp, ...metadata }) => { ({ level, label, message, timestamp, ...metadata }) => {
@ -29,10 +41,14 @@ const logger = winston.createLogger({
hformat hformat
), ),
}), }),
new winston.transports.File({ new winston.transports.DailyRotateFile({
filename: path.join(__dirname, '../config/logs/overseerr.log'), filename: path.join(__dirname, '../config/logs/overseerr-%DATE%.log'),
maxsize: 20971520, datePattern: 'YYYY-MM-DD',
maxFiles: 6, 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, mapCrew,
ExternalIds, ExternalIds,
mapExternalIds, mapExternalIds,
mapVideos,
} from './common'; } from './common';
import Media from '../entity/Media'; 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 { export interface MovieDetails {
id: number; id: number;
imdbId?: string; imdbId?: string;
@ -23,6 +40,7 @@ export interface MovieDetails {
originalTitle: string; originalTitle: string;
overview?: string; overview?: string;
popularity: number; popularity: number;
relatedVideos?: Video[];
posterPath?: string; posterPath?: string;
productionCompanies: ProductionCompany[]; productionCompanies: ProductionCompany[];
productionCountries: { productionCountries: {
@ -46,6 +64,12 @@ export interface MovieDetails {
cast: Cast[]; cast: Cast[];
crew: Crew[]; crew: Crew[];
}; };
collection?: {
id: number;
name: string;
posterPath?: string;
backdropPath?: string;
};
mediaInfo?: Media; mediaInfo?: Media;
externalIds: ExternalIds; externalIds: ExternalIds;
} }
@ -58,6 +82,7 @@ export const mapMovieDetails = (
adult: movie.adult, adult: movie.adult,
budget: movie.budget, budget: movie.budget,
genres: movie.genres, genres: movie.genres,
relatedVideos: mapVideos(movie.videos),
originalLanguage: movie.original_language, originalLanguage: movie.original_language,
originalTitle: movie.original_title, originalTitle: movie.original_title,
popularity: movie.popularity, popularity: movie.popularity,
@ -87,6 +112,14 @@ export const mapMovieDetails = (
cast: movie.credits.cast.map(mapCast), cast: movie.credits.cast.map(mapCast),
crew: movie.credits.crew.map(mapCrew), 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), externalIds: mapExternalIds(movie.external_ids),
mediaInfo: media, mediaInfo: media,
}); });

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

@ -2,8 +2,12 @@ import {
TmdbCreditCast, TmdbCreditCast,
TmdbCreditCrew, TmdbCreditCrew,
TmdbExternalIds, TmdbExternalIds,
TmdbVideo,
TmdbVideoResult,
} from '../api/themoviedb'; } from '../api/themoviedb';
import { Video } from '../models/Movie';
export interface ProductionCompany { export interface ProductionCompany {
id: number; id: number;
logoPath?: string; logoPath?: string;
@ -84,3 +88,18 @@ export const mapExternalIds = (eids: TmdbExternalIds): ExternalIds => ({
tvrageId: eids.tvrage_id, tvrageId: eids.tvrage_id,
twitterId: eids.twitter_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 tvRoutes from './tv';
import mediaRoutes from './media'; import mediaRoutes from './media';
import personRoutes from './person'; import personRoutes from './person';
import collectionRoutes from './collection';
const router = Router(); const router = Router();
@ -34,6 +35,7 @@ router.use('/movie', isAuthenticated(), movieRoutes);
router.use('/tv', isAuthenticated(), tvRoutes); router.use('/tv', isAuthenticated(), tvRoutes);
router.use('/media', isAuthenticated(), mediaRoutes); router.use('/media', isAuthenticated(), mediaRoutes);
router.use('/person', isAuthenticated(), personRoutes); router.use('/person', isAuthenticated(), personRoutes);
router.use('/collection', isAuthenticated(), collectionRoutes);
router.use('/auth', authRoutes); router.use('/auth', authRoutes);
router.get('/', (_req, res) => { router.get('/', (_req, res) => {

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

@ -127,12 +127,16 @@ requestRoutes.post(
media, media,
requestedBy: req.user, requestedBy: req.user,
// If the user is an admin or has the "auto approve" permission, automatically approve the request // If the user is an admin or has the "auto approve" permission, automatically approve the request
status: req.user?.hasPermission(Permission.AUTO_APPROVE) status:
? MediaRequestStatus.APPROVED req.user?.hasPermission(Permission.AUTO_APPROVE) ||
: MediaRequestStatus.PENDING, req.user?.hasPermission(Permission.AUTO_APPROVE_MOVIE)
modifiedBy: req.user?.hasPermission(Permission.AUTO_APPROVE) ? MediaRequestStatus.APPROVED
? req.user : MediaRequestStatus.PENDING,
: undefined, modifiedBy:
req.user?.hasPermission(Permission.AUTO_APPROVE) ||
req.user?.hasPermission(Permission.AUTO_APPROVE_MOVIE)
? req.user
: undefined,
}); });
await requestRepository.save(request); await requestRepository.save(request);
@ -172,19 +176,25 @@ requestRoutes.post(
} as Media, } as Media,
requestedBy: req.user, requestedBy: req.user,
// If the user is an admin or has the "auto approve" permission, automatically approve the request // If the user is an admin or has the "auto approve" permission, automatically approve the request
status: req.user?.hasPermission(Permission.AUTO_APPROVE) status:
? MediaRequestStatus.APPROVED req.user?.hasPermission(Permission.AUTO_APPROVE) ||
: MediaRequestStatus.PENDING, req.user?.hasPermission(Permission.AUTO_APPROVE_TV)
modifiedBy: req.user?.hasPermission(Permission.AUTO_APPROVE) ? MediaRequestStatus.APPROVED
? req.user : MediaRequestStatus.PENDING,
: undefined, modifiedBy:
req.user?.hasPermission(Permission.AUTO_APPROVE) ||
req.user?.hasPermission(Permission.AUTO_APPROVE_TV)
? req.user
: undefined,
seasons: finalSeasons.map( seasons: finalSeasons.map(
(sn) => (sn) =>
new SeasonRequest({ new SeasonRequest({
seasonNumber: sn, seasonNumber: sn,
status: req.user?.hasPermission(Permission.AUTO_APPROVE) status:
? MediaRequestStatus.APPROVED req.user?.hasPermission(Permission.AUTO_APPROVE) ||
: MediaRequestStatus.PENDING, 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<{ requestRoutes.get<{
requestId: string; requestId: string;
status: 'pending' | 'approve' | 'decline'; status: 'pending' | 'approve' | 'decline';

@ -24,6 +24,7 @@ import { SettingsAboutResponse } from '../interfaces/api/settingsInterfaces';
import { Notification } from '../lib/notifications'; import { Notification } from '../lib/notifications';
import DiscordAgent from '../lib/notifications/agents/discord'; import DiscordAgent from '../lib/notifications/agents/discord';
import EmailAgent from '../lib/notifications/agents/email'; import EmailAgent from '../lib/notifications/agents/email';
import SlackAgent from '../lib/notifications/agents/slack';
const settingsRoutes = Router(); const settingsRoutes = Router();
@ -468,6 +469,40 @@ settingsRoutes.post('/notifications/discord/test', (req, res, next) => {
return res.status(204).send(); 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) => { settingsRoutes.get('/notifications/email', (_req, res) => {
const settings = getSettings(); 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"> <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"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M31 20.2C32.9096 20.2 34.7409 19.4414 36.0912 18.0912C37.4414 16.7409 38.2 14.9095 38.2 13C38.2 11.0904 37.4414 9.25908 36.0912 7.90882C34.7409 6.55856 32.9096 5.79999 31 5.79999C29.0904 5.79999 27.2591 6.55856 25.9088 7.90882C24.5586 9.25908 23.8 11.0904 23.8 13C23.8 14.9095 24.5586 16.7409 25.9088 18.0912C27.2591 19.4414 29.0904 20.2 31 20.2ZM31.9 9.39999C31.9 9.16129 31.8052 8.93237 31.6364 8.76359C31.4676 8.59481 31.2387 8.49999 31 8.49999C30.7613 8.49999 30.5324 8.59481 30.3636 8.76359C30.1948 8.93237 30.1 9.16129 30.1 9.39999V13C30.1 13.2387 30.1949 13.4675 30.3637 13.6363L32.9089 16.1824C32.9925 16.266 33.0918 16.3323 33.201 16.3776C33.3103 16.4228 33.4274 16.4461 33.5456 16.4461C33.6639 16.4461 33.781 16.4228 33.8903 16.3776C33.9995 16.3323 34.0988 16.266 34.1824 16.1824C34.266 16.0988 34.3323 15.9995 34.3776 15.8902C34.4229 15.781 34.4461 15.6639 34.4461 15.5456C34.4461 15.4274 34.4229 15.3103 34.3776 15.201C34.3323 15.0918 34.266 14.9925 34.1824 14.9089L31.9 12.6274V9.39999Z" fill="white"/>
</svg> </svg>

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 { interface AlertProps {
title: string; title: string;
type?: 'warning'; type?: 'warning' | 'info';
} }
const Alert: React.FC<AlertProps> = ({ title, children }) => { const Alert: React.FC<AlertProps> = ({ title, children, type }) => {
return ( let design = {
<div className="rounded-md bg-yellow-600 p-4 mb-8"> bgColor: 'bg-yellow-600',
<div className="flex"> titleColor: 'text-yellow-200',
<div className="flex-shrink-0"> 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 <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" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
> >
<path <path
fillRule="evenodd" strokeLinecap="round"
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" strokeLinejoin="round"
clipRule="evenodd" strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/> />
</svg> </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"> <div className="ml-3">
<h3 className="text-sm font-medium text-yellow-200">{title}</h3> <h3 className={`text-sm font-medium ${design.titleColor}`}>
<div className="mt-2 text-sm text-yellow-300">{children}</div> {title}
</h3>
<div className={`mt-2 text-sm ${design.textColor}`}>{children}</div>
</div> </div>
</div> </div>
</div> </div>

@ -51,7 +51,7 @@ const Button: React.FC<ButtonProps> = ({
break; break;
default: default:
buttonStyle.push( 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 ...props
}) => ( }) => (
<a <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} {...props}
> >
{children} {children}
@ -31,6 +31,7 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
text, text,
children, children,
dropdownIcon, dropdownIcon,
className,
...props ...props
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@ -38,22 +39,22 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
useClickOutside(buttonRef, () => setIsOpen(false)); useClickOutside(buttonRef, () => setIsOpen(false));
return ( return (
<span className="relative z-0 inline-flex shadow-sm rounded-md"> <span className="relative z-0 inline-flex rounded-md shadow-sm">
<button <button
type="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 ${ 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' children ? 'rounded-l-md' : 'rounded-md'
}`} } ${className}`}
ref={buttonRef} ref={buttonRef}
{...props} {...props}
> >
{text} {text}
</button> </button>
<span className="-ml-px relative block"> <span className="relative block -ml-px">
{children && ( {children && (
<button <button
type="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" aria-label="Expand"
onClick={() => setIsOpen((state) => !state)} onClick={() => setIsOpen((state) => !state)}
> >
@ -61,7 +62,7 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
dropdownIcon dropdownIcon
) : ( ) : (
<svg <svg
className="h-5 w-5" className="w-5 h-5"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"
@ -84,8 +85,8 @@ const ButtonWithDropdown: React.FC<ButtonWithDropdownProps> = ({
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" 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="absolute right-0 w-56 mt-2 -mr-1 origin-top-right rounded-md shadow-lg">
<div className="rounded-md bg-indigo-600 ring-1 ring-black ring-opacity-5"> <div className="bg-indigo-600 rounded-md ring-1 ring-black ring-opacity-5">
<div className="py-1">{children}</div> <div className="py-1">{children}</div>
</div> </div>
</div> </div>

@ -23,7 +23,6 @@ import {
MediaRequestStatus, MediaRequestStatus,
} from '../../../server/constants/media'; } from '../../../server/constants/media';
import RequestModal from '../RequestModal'; import RequestModal from '../RequestModal';
import Badge from '../Common/Badge';
import ButtonWithDropdown from '../Common/ButtonWithDropdown'; import ButtonWithDropdown from '../Common/ButtonWithDropdown';
import axios from 'axios'; import axios from 'axios';
import SlideOver from '../Common/SlideOver'; 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 type { RTRating } from '../../../server/api/rottentomatoes';
import Error from '../../pages/_error'; import Error from '../../pages/_error';
import Head from 'next/head'; import Head from 'next/head';
import globalMessages from '../../i18n/globalMessages';
import ExternalLinkBlock from '../ExternalLinkBlock'; import ExternalLinkBlock from '../ExternalLinkBlock';
import { sortCrewPriority } from '../../utils/creditHelpers'; import { sortCrewPriority } from '../../utils/creditHelpers';
import StatusBadge from '../StatusBadge';
const messages = defineMessages({ const messages = defineMessages({
releasedate: 'Release Date', releasedate: 'Release Date',
@ -46,6 +45,7 @@ const messages = defineMessages({
status: 'Status', status: 'Status',
revenue: 'Revenue', revenue: 'Revenue',
budget: 'Budget', budget: 'Budget',
watchtrailer: 'Watch Trailer',
originallanguage: 'Original Language', originallanguage: 'Original Language',
overview: 'Overview', overview: 'Overview',
runtime: '{minutes} minutes', runtime: '{minutes} minutes',
@ -69,6 +69,7 @@ const messages = defineMessages({
decline: 'Decline', decline: 'Decline',
studio: 'Studio', studio: 'Studio',
viewfullcrew: 'View Full Crew', viewfullcrew: 'View Full Crew',
view: 'View',
}); });
interface MovieDetailsProps { interface MovieDetailsProps {
@ -121,6 +122,11 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
(request) => request.status === MediaRequestStatus.PENDING (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 modifyRequest = async (type: 'approve' | 'decline') => {
const response = await axios.get( const response = await axios.get(
`/api/v1/request/${activeRequest?.id}/${type}` `/api/v1/request/${activeRequest?.id}/${type}`
@ -200,37 +206,23 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</div> </div>
)} )}
</SlideOver> </SlideOver>
<div className="flex flex-col items-center pt-4 md:flex-row md:items-end"> <div className="flex flex-col items-center pt-4 lg:flex-row lg:items-end">
<div className="flex-shrink-0 md:mr-4"> <div className="lg:mr-4">
<img <img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`} src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
alt="" 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>
<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"> <div className="mb-2">
{data.mediaInfo?.status === MediaStatus.AVAILABLE && ( <StatusBadge status={data.mediaInfo?.status} />
<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>
)}
</div> </div>
<h1 className="text-2xl md:text-4xl"> <h1 className="text-2xl lg:text-4xl">
{data.title}{' '} {data.title}{' '}
<span className="text-2xl">({data.releaseDate.slice(0, 4)})</span> <span className="text-2xl">({data.releaseDate.slice(0, 4)})</span>
</h1> </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 && ( {(data.runtime ?? 0) > 0 && (
<> <>
<FormattedMessage <FormattedMessage
@ -243,16 +235,44 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
{data.genres.map((g) => g.name).join(', ')} {data.genres.map((g) => g.name).join(', ')}
</span> </span>
</div> </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 ||
data.mediaInfo?.status === MediaStatus.UNKNOWN) && ( data.mediaInfo?.status === MediaStatus.UNKNOWN) && (
<Button <Button
buttonType="primary" buttonType="primary"
className="ml-2"
onClick={() => setShowRequestModal(true)} onClick={() => setShowRequestModal(true)}
> >
{activeRequest ? ( {activeRequest ? (
<svg <svg
className="w-4 mr-1" className="w-5 mr-1"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -265,7 +285,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</svg> </svg>
) : ( ) : (
<svg <svg
className="w-4 mr-1" className="w-5 mr-1"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -316,6 +336,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
</> </>
} }
onClick={() => setShowRequestModal(true)} onClick={() => setShowRequestModal(true)}
className="ml-2"
> >
{hasPermission(Permission.MANAGE_REQUESTS) && ( {hasPermission(Permission.MANAGE_REQUESTS) && (
<> <>
@ -438,6 +459,27 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
)} )}
</div> </div>
<div className="w-full mt-8 md:w-80 md:mt-0"> <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"> <div className="bg-gray-900 border border-gray-800 rounded-lg shadow">
{(data.voteCount > 0 || ratingData) && ( {(data.voteCount > 0 || ratingData) && (
<div className="flex items-center justify-center px-4 py-2 border-b border-gray-800 last:border-b-0"> <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 sortedCast = combinedCredits?.cast.sort((a, b) => {
const aDate = const aVotes = a.voteCount ?? 0;
a.mediaType === 'movie' const bVotes = b.voteCount ?? 0;
? a.releaseDate?.slice(0, 4) ?? 0 if (aVotes > bVotes) {
: 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) {
return -1; return -1;
} }
return 1; return 1;
}); });
const sortedCrew = combinedCredits?.crew.sort((a, b) => { const sortedCrew = combinedCredits?.crew.sort((a, b) => {
const aDate = const aVotes = a.voteCount ?? 0;
a.mediaType === 'movie' const bVotes = b.voteCount ?? 0;
? a.releaseDate?.slice(0, 4) ?? 0 if (aVotes > bVotes) {
: 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) {
return -1; return -1;
} }
return 1; return 1;
@ -75,6 +63,94 @@ const PersonDetails: React.FC = () => {
const isLoading = !combinedCredits && !errorCombinedCredits; 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 ( return (
<> <>
{(sortedCrew || sortedCast) && ( {(sortedCrew || sortedCast) && (
@ -126,96 +202,7 @@ const PersonDetails: React.FC = () => {
</div> </div>
</div> </div>
</div> </div>
{(sortedCast ?? []).length > 0 && ( {data.knownForDepartment === 'Acting' ? [cast, crew] : [crew, cast]}
<>
<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>
</>
)}
{isLoading && <LoadingSpinner />} {isLoading && <LoadingSpinner />}
</> </>
); );

@ -1,4 +1,4 @@
import React, { useContext } from 'react'; import React, { useContext, useState } from 'react';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
import type { MediaRequest } from '../../../../server/entity/MediaRequest'; import type { MediaRequest } from '../../../../server/entity/MediaRequest';
import { import {
@ -15,16 +15,21 @@ import useSWR from 'swr';
import Badge from '../../Common/Badge'; import Badge from '../../Common/Badge';
import StatusBadge from '../../StatusBadge'; import StatusBadge from '../../StatusBadge';
import Table from '../../Common/Table'; import Table from '../../Common/Table';
import { MediaRequestStatus } from '../../../../server/constants/media'; import {
MediaRequestStatus,
MediaStatus,
} from '../../../../server/constants/media';
import Button from '../../Common/Button'; import Button from '../../Common/Button';
import axios from 'axios'; import axios from 'axios';
import globalMessages from '../../../i18n/globalMessages'; import globalMessages from '../../../i18n/globalMessages';
import Link from 'next/link'; import Link from 'next/link';
import { useToasts } from 'react-toast-notifications';
const messages = defineMessages({ const messages = defineMessages({
requestedby: 'Requested by {username}', requestedby: 'Requested by {username}',
seasons: 'Seasons', seasons: 'Seasons',
notavailable: 'N/A', notavailable: 'N/A',
failedretry: 'Something went wrong retrying the request',
}); });
const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
@ -33,13 +38,17 @@ const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => {
interface RequestItemProps { interface RequestItemProps {
request: MediaRequest; request: MediaRequest;
onDelete: () => void; revalidateList: () => void;
} }
const RequestItem: React.FC<RequestItemProps> = ({ request, onDelete }) => { const RequestItem: React.FC<RequestItemProps> = ({
request,
revalidateList,
}) => {
const { ref, inView } = useInView({ const { ref, inView } = useInView({
triggerOnce: true, triggerOnce: true,
}); });
const { addToast } = useToasts();
const intl = useIntl(); const intl = useIntl();
const { hasPermission } = useUser(); const { hasPermission } = useUser();
const { locale } = useContext(LanguageContext); const { locale } = useContext(LanguageContext);
@ -50,13 +59,15 @@ const RequestItem: React.FC<RequestItemProps> = ({ request, onDelete }) => {
const { data: title, error } = useSWR<MovieDetails | TvDetails>( const { data: title, error } = useSWR<MovieDetails | TvDetails>(
inView ? `${url}?language=${locale}` : null inView ? `${url}?language=${locale}` : null
); );
const { data: requestData, revalidate } = useSWR<MediaRequest>( const { data: requestData, revalidate, mutate } = useSWR<MediaRequest>(
`/api/v1/request/${request.id}`, `/api/v1/request/${request.id}`,
{ {
initialData: request, initialData: request,
} }
); );
const [isRetrying, setRetrying] = useState(false);
const modifyRequest = async (type: 'approve' | 'decline') => { const modifyRequest = async (type: 'approve' | 'decline') => {
const response = await axios.get(`/api/v1/request/${request.id}/${type}`); 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 () => { const deleteRequest = async () => {
await axios.delete(`/api/v1/request/${request.id}`); 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) { if (!title && !error) {
@ -138,7 +165,13 @@ const RequestItem: React.FC<RequestItemProps> = ({ request, onDelete }) => {
)} )}
</Table.TD> </Table.TD>
<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>
<Table.TD> <Table.TD>
<div className="flex flex-col"> <div className="flex flex-col">
@ -167,6 +200,31 @@ const RequestItem: React.FC<RequestItemProps> = ({ request, onDelete }) => {
</div> </div>
</Table.TD> </Table.TD>
<Table.TD alignText="right"> <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 && {requestData.status !== MediaRequestStatus.PENDING &&
hasPermission(Permission.MANAGE_REQUESTS) && ( hasPermission(Permission.MANAGE_REQUESTS) && (
<Button <Button

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

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

@ -223,13 +223,13 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
} }
> >
<div className="flex flex-col"> <div className="flex flex-col">
<div className="-mx-4 sm:mx-0 overflow-auto max-h-96"> <div className="-mx-4 overflow-auto sm:mx-0 max-h-96">
<div className="py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"> <div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<div className="shadow overflow-hidden sm:rounded-lg"> <div className="overflow-hidden shadow sm:rounded-lg">
<table className="min-w-full"> <table className="min-w-full">
<thead> <thead>
<tr> <tr>
<th className="px-4 py-3 bg-gray-500 w-16"> <th className="w-16 px-4 py-3 bg-gray-500">
<span <span
role="checkbox" role="checkbox"
tabIndex={0} tabIndex={0}
@ -240,7 +240,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
toggleAllSeasons(); 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 <span
aria-hidden="true" aria-hidden="true"
@ -256,13 +256,13 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
></span> ></span>
</span> </span>
</th> </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)} {intl.formatMessage(messages.season)}
</th> </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)} {intl.formatMessage(messages.numberofepisodes)}
</th> </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)} {intl.formatMessage(messages.status)}
</th> </th>
</tr> </tr>
@ -279,7 +279,7 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
); );
return ( return (
<tr key={`season-${season.id}`}> <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 <span
role="checkbox" role="checkbox"
tabIndex={0} tabIndex={0}
@ -320,17 +320,17 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
></span> ></span>
</span> </span>
</td> </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 {season.seasonNumber === 0
? intl.formatMessage(messages.extras) ? intl.formatMessage(messages.extras)
: intl.formatMessage(messages.seasonnumber, { : intl.formatMessage(messages.seasonnumber, {
number: season.seasonNumber, number: season.seasonNumber,
})} })}
</td> </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} {season.episodeCount}
</td> </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 && ( {!seasonRequest && !mediaSeason && (
<Badge> <Badge>
{intl.formatMessage(messages.notrequested)} {intl.formatMessage(messages.notrequested)}
@ -346,10 +346,8 @@ const TvRequestModal: React.FC<RequestModalProps> = ({
{!mediaSeason && {!mediaSeason &&
seasonRequest?.status === seasonRequest?.status ===
MediaRequestStatus.APPROVED && ( MediaRequestStatus.APPROVED && (
<Badge badgeType="danger"> <Badge badgeType="primary">
{intl.formatMessage( {intl.formatMessage(globalMessages.requested)}
globalMessages.unavailable
)}
</Badge> </Badge>
)} )}
{!mediaSeason && {!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 Button from '../../../Common/Button';
import Modal from '../../../Common/Modal'; import Modal from '../../../Common/Modal';
import Transition from '../../../Transition'; import Transition from '../../../Transition';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, FormattedRelativeTime, useIntl } from 'react-intl';
import globalMessages from '../../../../i18n/globalMessages'; import globalMessages from '../../../../i18n/globalMessages';
const messages = defineMessages({ const messages = defineMessages({
@ -59,7 +59,7 @@ const Release: React.FC<ReleaseProps> = ({
const intl = useIntl(); const intl = useIntl();
const [isModalOpen, setModalOpen] = useState(false); const [isModalOpen, setModalOpen] = useState(false);
return ( 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 <Transition
enter="opacity-0 transition duration-300" enter="opacity-0 transition duration-300"
enterFrom="opacity-0" enterFrom="opacity-0"
@ -99,7 +99,16 @@ const Release: React.FC<ReleaseProps> = ({
</div> </div>
</Modal> </Modal>
</Transition> </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> <span className="text-xl">{release.name}</span>
{isLatest && ( {isLatest && (
<span className="ml-2"> <span className="ml-2">
@ -147,7 +156,7 @@ const Releases: React.FC<ReleasesProps> = ({ currentVersion }) => {
return ( return (
<div> <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)} {intl.formatMessage(messages.releases)}
</div> </div>
{currentVersion.startsWith('develop-') && ( {currentVersion.startsWith('develop-') && (
@ -159,7 +168,7 @@ const Releases: React.FC<ReleasesProps> = ({ currentVersion }) => {
href="https://github.com/sct/overseerr" href="https://github.com/sct/overseerr"
target="_blank" target="_blank"
rel="noreferrer" 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} {msg}
</a> </a>

@ -10,7 +10,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { useUser, Permission } from '../../hooks/useUser'; import { useUser, Permission } from '../../hooks/useUser';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import { messages as permissionMessages } from '../UserEdit'; import { messages as permissionMessages } from '../UserEdit';
import { hasPermission } from '../../../server/lib/permissions'; import PermissionOption, { PermissionItem } from '../PermissionOption';
const messages = defineMessages({ const messages = defineMessages({
generalsettings: 'General Settings', generalsettings: 'General Settings',
@ -27,13 +27,6 @@ const messages = defineMessages({
defaultPermissions: 'Default User Permissions', defaultPermissions: 'Default User Permissions',
}); });
interface PermissionOption {
id: string;
name: string;
description: string;
permission: Permission;
}
const SettingsMain: React.FC = () => { const SettingsMain: React.FC = () => {
const { addToast } = useToasts(); const { addToast } = useToasts();
const { hasPermission: userHasPermission } = useUser(); const { hasPermission: userHasPermission } = useUser();
@ -63,7 +56,7 @@ const SettingsMain: React.FC = () => {
return <LoadingSpinner />; return <LoadingSpinner />;
} }
const permissionList: PermissionOption[] = [ const permissionList: PermissionItem[] = [
{ {
id: 'admin', id: 'admin',
name: intl.formatMessage(permissionMessages.admin), name: intl.formatMessage(permissionMessages.admin),
@ -96,12 +89,6 @@ const SettingsMain: React.FC = () => {
description: intl.formatMessage(permissionMessages.requestDescription), description: intl.formatMessage(permissionMessages.requestDescription),
permission: Permission.REQUEST, permission: Permission.REQUEST,
}, },
{
id: 'vote',
name: intl.formatMessage(permissionMessages.vote),
description: intl.formatMessage(permissionMessages.voteDescription),
permission: Permission.VOTE,
},
{ {
id: 'autoapprove', id: 'autoapprove',
name: intl.formatMessage(permissionMessages.autoapprove), name: intl.formatMessage(permissionMessages.autoapprove),
@ -109,6 +96,24 @@ const SettingsMain: React.FC = () => {
permissionMessages.autoapproveDescription permissionMessages.autoapproveDescription
), ),
permission: Permission.AUTO_APPROVE, 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>
<div className="mt-4 sm:mt-0 sm:col-span-2"> <div className="mt-4 sm:mt-0 sm:col-span-2">
<div className="max-w-lg"> <div className="max-w-lg">
{permissionList.map((permissionOption) => ( {permissionList.map((permissionItem) => (
<div <PermissionOption
className={`relative flex items-start first:mt-0 mt-4 ${ key={`permission-option-${permissionItem.id}`}
permissionOption.permission !== option={permissionItem}
Permission.ADMIN && currentPermission={values.defaultPermissions}
hasPermission( onUpdate={(newPermissions) =>
Permission.ADMIN, setFieldValue(
values.defaultPermissions '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>
</div> </div>

@ -2,6 +2,8 @@ import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React from 'react'; import React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import DiscordLogo from '../../assets/extlogos/discord_white.svg';
import SlackLogo from '../../assets/extlogos/slack.svg';
const messages = defineMessages({ const messages = defineMessages({
notificationsettings: 'Notification Settings', notificationsettings: 'Notification Settings',
@ -11,6 +13,7 @@ const messages = defineMessages({
interface SettingsRoute { interface SettingsRoute {
text: string; text: string;
content: React.ReactNode;
route: string; route: string;
regex: RegExp; regex: RegExp;
} }
@ -18,23 +21,59 @@ interface SettingsRoute {
const settingsRoutes: SettingsRoute[] = [ const settingsRoutes: SettingsRoute[] = [
{ {
text: 'Email', 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', route: '/settings/notifications/email',
regex: /^\/settings\/notifications\/email/, regex: /^\/settings\/notifications\/email/,
}, },
{ {
text: 'Discord', text: 'Discord',
content: (
<span className="flex items-center">
<DiscordLogo className="h-4 mr-2" />
Discord
</span>
),
route: '/settings/notifications/discord', route: '/settings/notifications/discord',
regex: /^\/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 SettingsNotifications: React.FC = ({ children }) => {
const router = useRouter(); const router = useRouter();
const intl = useIntl(); const intl = useIntl();
const activeLinkColor = 'bg-gray-700'; const activeLinkColor = 'bg-indigo-700';
const inactiveLinkColor = ''; const inactiveLinkColor = 'bg-gray-800';
const SettingsLink: React.FC<{ const SettingsLink: React.FC<{
route: string; route: string;
@ -62,10 +101,10 @@ const SettingsNotifications: React.FC = ({ children }) => {
return ( return (
<> <>
<div className="mb-6"> <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)} {intl.formatMessage(messages.notificationsettings)}
</h3> </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)} {intl.formatMessage(messages.notificationsettingsDescription)}
</p> </p>
</div> </div>
@ -87,7 +126,7 @@ const SettingsNotifications: React.FC = ({ children }) => {
)?.route )?.route
} }
aria-label="Selected tab" 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) => ( {settingsRoutes.map((route, index) => (
<SettingsLink <SettingsLink
@ -109,7 +148,7 @@ const SettingsNotifications: React.FC = ({ children }) => {
regex={route.regex} regex={route.regex}
key={`standard-settings-link-${index}`} key={`standard-settings-link-${index}`}
> >
{route.text} {route.content}
</SettingsLink> </SettingsLink>
))} ))}
</nav> </nav>

@ -5,36 +5,40 @@ import { useIntl } from 'react-intl';
import globalMessages from '../../i18n/globalMessages'; import globalMessages from '../../i18n/globalMessages';
interface StatusBadgeProps { interface StatusBadgeProps {
status: MediaStatus; status?: MediaStatus;
} }
const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => { const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
const intl = useIntl(); const intl = useIntl();
return ( switch (status) {
<> case MediaStatus.AVAILABLE:
{status === MediaStatus.AVAILABLE && ( return (
<Badge badgeType="success"> <Badge badgeType="success">
{intl.formatMessage(globalMessages.available)} {intl.formatMessage(globalMessages.available)}
</Badge> </Badge>
)} );
{status === MediaStatus.PARTIALLY_AVAILABLE && ( case MediaStatus.PARTIALLY_AVAILABLE:
return (
<Badge badgeType="success"> <Badge badgeType="success">
{intl.formatMessage(globalMessages.partiallyavailable)} {intl.formatMessage(globalMessages.partiallyavailable)}
</Badge> </Badge>
)} );
{status === MediaStatus.PROCESSING && ( case MediaStatus.PROCESSING:
<Badge badgeType="danger"> return (
{intl.formatMessage(globalMessages.unavailable)} <Badge badgeType="primary">
{intl.formatMessage(globalMessages.requested)}
</Badge> </Badge>
)} );
{status === MediaStatus.PENDING && ( case MediaStatus.PENDING:
return (
<Badge badgeType="warning"> <Badge badgeType="warning">
{intl.formatMessage(globalMessages.pending)} {intl.formatMessage(globalMessages.pending)}
</Badge> </Badge>
)} );
</> default:
); return null;
}
}; };
export default StatusBadge; 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 type { MediaType } from '../../../server/models/Search';
import Available from '../../assets/available.svg'; import Available from '../../assets/available.svg';
import Requested from '../../assets/requested.svg'; import Requested from '../../assets/requested.svg';
@ -51,6 +51,10 @@ const TitleCard: React.FC<TitleCardProps> = ({
year = year.slice(0, 4); year = year.slice(0, 4);
} }
useEffect(() => {
setCurrentStatus(status);
}, [status]);
const requestComplete = useCallback((newStatus: MediaStatus) => { const requestComplete = useCallback((newStatus: MediaStatus) => {
setCurrentStatus(newStatus); setCurrentStatus(newStatus);
setShowRequestModal(false); setShowRequestModal(false);
@ -74,7 +78,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
onCancel={closeModal} onCancel={closeModal}
/> />
<div <div
className="titleCard outline-none cursor-default" className="outline-none cursor-default titleCard"
style={{ style={{
backgroundImage: `url(//image.tmdb.org/t/p/w300_and_h450_face${image})`, backgroundImage: `url(//image.tmdb.org/t/p/w300_and_h450_face${image})`,
}} }}
@ -93,13 +97,13 @@ const TitleCard: React.FC<TitleCardProps> = ({
role="link" role="link"
tabIndex={0} 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 <div
className={`absolute left-0 top-0 rounded-tl-md rounded-br-md z-40 ${ className={`absolute left-0 top-0 rounded-tl-md rounded-br-md z-40 ${
mediaType === 'movie' ? 'bg-blue-500' : 'bg-purple-600' 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' {mediaType === 'movie'
? intl.formatMessage(messages.movie) ? intl.formatMessage(messages.movie)
: intl.formatMessage(messages.tvshow)} : intl.formatMessage(messages.tvshow)}
@ -107,7 +111,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
</div> </div>
<div <div
className="absolute right-0 top-0 z-40" className="absolute top-0 right-0 z-40"
style={{ style={{
right: '-1px', right: '-1px',
}} }}
@ -132,7 +136,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" 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 <svg
className="w-10 h-10 animate-spin" className="w-10 h-10 animate-spin"
fill="none" fill="none"
@ -159,155 +163,155 @@ const TitleCard: React.FC<TitleCardProps> = ({
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<Link href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}> <div className="absolute top-0 bottom-0 left-0 right-0">
<a <Link href={mediaType === 'movie' ? `/movie/${id}` : `/tv/${id}`}>
className="absolute w-full text-left top-0 right-0 left-0 bottom-0 rounded-lg overflow-hidden cursor-pointer" <a
style={{ className="absolute top-0 bottom-0 left-0 right-0 w-full h-full overflow-hidden text-left rounded-lg cursor-pointer"
background: style={{
'linear-gradient(180deg, rgba(45, 55, 72, 0.4) 0%, rgba(45, 55, 72, 0.9) 100%)', 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"> <div className="flex items-end w-full h-full">
{year && <div className="text-sm">{year}</div>} <div className="px-2 text-white pb-11">
{year && <div className="text-sm">{year}</div>}
<h1 className="text-xl leading-tight whitespace-normal"> <h1 className="text-xl leading-tight whitespace-normal">
{title} {title}
</h1> </h1>
<div <div
className="text-xs whitespace-normal" className="text-xs whitespace-normal"
style={{ style={{
WebkitLineClamp: 3, WebkitLineClamp: 3,
display: '-webkit-box', display: '-webkit-box',
overflow: 'hidden', overflow: 'hidden',
WebkitBoxOrient: 'vertical', 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);
}} }}
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 {summary}
className="w-4 mx-auto" </div>
fill="none" </div>
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>
</div> </a>
</a> </Link>
</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> </Transition>
</div> </div>
</div> </div>

@ -19,7 +19,6 @@ import { useUser, Permission } from '../../hooks/useUser';
import { TvDetails as TvDetailsType } from '../../../server/models/Tv'; import { TvDetails as TvDetailsType } from '../../../server/models/Tv';
import { MediaStatus } from '../../../server/constants/media'; import { MediaStatus } from '../../../server/constants/media';
import RequestModal from '../RequestModal'; import RequestModal from '../RequestModal';
import Badge from '../Common/Badge';
import ButtonWithDropdown from '../Common/ButtonWithDropdown'; import ButtonWithDropdown from '../Common/ButtonWithDropdown';
import axios from 'axios'; import axios from 'axios';
import SlideOver from '../Common/SlideOver'; 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 RTAudRotten from '../../assets/rt_aud_rotten.svg';
import type { RTRating } from '../../../server/api/rottentomatoes'; import type { RTRating } from '../../../server/api/rottentomatoes';
import Head from 'next/head'; import Head from 'next/head';
import globalMessages from '../../i18n/globalMessages';
import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '../../../server/api/themoviedb';
import ExternalLinkBlock from '../ExternalLinkBlock'; import ExternalLinkBlock from '../ExternalLinkBlock';
import { sortCrewPriority } from '../../utils/creditHelpers'; import { sortCrewPriority } from '../../utils/creditHelpers';
import { Crew } from '../../../server/models/common'; import { Crew } from '../../../server/models/common';
import StatusBadge from '../StatusBadge';
const messages = defineMessages({ const messages = defineMessages({
firstAirDate: 'First Air Date', firstAirDate: 'First Air Date',
@ -48,6 +47,7 @@ const messages = defineMessages({
recommendations: 'Recommendations', recommendations: 'Recommendations',
similar: 'Similar Series', similar: 'Similar Series',
cancelrequest: 'Cancel Request', cancelrequest: 'Cancel Request',
watchtrailer: 'Watch Trailer',
available: 'Available', available: 'Available',
unavailable: 'Unavailable', unavailable: 'Unavailable',
request: 'Request', request: 'Request',
@ -130,6 +130,11 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
(request) => request.status === MediaRequestStatus.PENDING (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> => { const modifyRequests = async (type: 'approve' | 'decline'): Promise<void> => {
if (!activeRequests) { if (!activeRequests) {
return; return;
@ -221,38 +226,19 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</div> </div>
)} )}
</SlideOver> </SlideOver>
<div className="flex flex-col items-center pt-4 md:flex-row md:items-end"> <div className="flex flex-col items-center pt-4 lg:flex-row lg:items-end">
<div className="flex-shrink-0 md:mr-4"> <div className="lg:mr-4">
<img <img
src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`} src={`//image.tmdb.org/t/p/w600_and_h900_bestv2${data.posterPath}`}
alt="" 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>
<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"> <div className="mb-2">
{data.mediaInfo?.status === MediaStatus.AVAILABLE && ( <StatusBadge status={data.mediaInfo?.status} />
<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>
)}
</div> </div>
<h1 className="text-2xl md:text-4xl"> <h1 className="text-2xl lg:text-4xl">
<span>{data.name}</span> <span>{data.name}</span>
{data.firstAirDate && ( {data.firstAirDate && (
<span className="ml-2 text-2xl"> <span className="ml-2 text-2xl">
@ -260,19 +246,47 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
</span> </span>
)} )}
</h1> </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(', ')} {data.genres.map((g) => g.name).join(', ')}
</span> </span>
</div> </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 ||
data.mediaInfo.status === MediaStatus.UNKNOWN) && ( data.mediaInfo.status === MediaStatus.UNKNOWN) && (
<Button <Button
className="ml-2"
buttonType="primary" buttonType="primary"
onClick={() => setShowRequestModal(true)} onClick={() => setShowRequestModal(true)}
> >
<svg <svg
className="w-4 mr-1" className="w-5 mr-1"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -309,7 +323,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
text={ text={
<> <>
<svg <svg
className="w-4 mr-1" className="w-5 mr-1"
fill="currentColor" fill="currentColor"
viewBox="0 0 20 20" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@ -323,6 +337,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
<FormattedMessage {...messages.requestmore} /> <FormattedMessage {...messages.requestmore} />
</> </>
} }
className="ml-2"
onClick={() => setShowRequestModal(true)} onClick={() => setShowRequestModal(true)}
> >
{hasPermission(Permission.MANAGE_REQUESTS) && {hasPermission(Permission.MANAGE_REQUESTS) &&

@ -2,12 +2,12 @@ import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import LoadingSpinner from '../Common/LoadingSpinner'; import LoadingSpinner from '../Common/LoadingSpinner';
import { Permission, useUser } from '../../hooks/useUser'; import { Permission, useUser } from '../../hooks/useUser';
import { hasPermission } from '../../../server/lib/permissions';
import Button from '../Common/Button'; import Button from '../Common/Button';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl'; import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import axios from 'axios'; import axios from 'axios';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import Header from '../Common/Header'; import Header from '../Common/Header';
import PermissionOption, { PermissionItem } from '../PermissionOption';
export const messages = defineMessages({ export const messages = defineMessages({
edituser: 'Edit User', edituser: 'Edit User',
@ -35,25 +35,24 @@ export const messages = defineMessages({
autoapprove: 'Auto Approve', autoapprove: 'Auto Approve',
autoapproveDescription: autoapproveDescription:
'Grants auto approval for any requests made by this user.', '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', save: 'Save',
saving: 'Saving...', saving: 'Saving...',
usersaved: 'User saved', usersaved: 'User saved',
userfail: 'Something went wrong saving the user.', userfail: 'Something went wrong saving the user.',
}); });
interface PermissionOption {
id: string;
name: string;
description: string;
permission: Permission;
}
const UserEdit: React.FC = () => { const UserEdit: React.FC = () => {
const router = useRouter(); const router = useRouter();
const intl = useIntl(); const intl = useIntl();
const { addToast } = useToasts(); const { addToast } = useToasts();
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
const { user: currentUser, hasPermission: currentHasPermission } = useUser(); const { user: currentUser } = useUser();
const { user, error, revalidate } = useUser({ const { user, error, revalidate } = useUser({
id: Number(router.query.userId), id: Number(router.query.userId),
}); });
@ -97,7 +96,7 @@ const UserEdit: React.FC = () => {
return <LoadingSpinner />; return <LoadingSpinner />;
} }
const permissionList: PermissionOption[] = [ const permissionList: PermissionItem[] = [
{ {
id: 'admin', id: 'admin',
name: intl.formatMessage(messages.admin), name: intl.formatMessage(messages.admin),
@ -128,17 +127,29 @@ const UserEdit: React.FC = () => {
description: intl.formatMessage(messages.requestDescription), description: intl.formatMessage(messages.requestDescription),
permission: Permission.REQUEST, permission: Permission.REQUEST,
}, },
{
id: 'vote',
name: intl.formatMessage(messages.vote),
description: intl.formatMessage(messages.voteDescription),
permission: Permission.VOTE,
},
{ {
id: 'autoapprove', id: 'autoapprove',
name: intl.formatMessage(messages.autoapprove), name: intl.formatMessage(messages.autoapprove),
description: intl.formatMessage(messages.autoapproveDescription), description: intl.formatMessage(messages.autoapproveDescription),
permission: Permission.AUTO_APPROVE, 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>
<div className="mt-4 sm:mt-0 sm:col-span-2"> <div className="mt-4 sm:mt-0 sm:col-span-2">
<div className="max-w-lg"> <div className="max-w-lg">
{permissionList.map((permissionOption) => ( {permissionList.map((permissionItem) => (
<div <PermissionOption
className={`relative flex items-start first:mt-0 mt-4 ${ key={`permission-option-${permissionItem.id}`}
(permissionOption.permission !== Permission.ADMIN && option={permissionItem}
hasPermission( user={currentUser}
Permission.ADMIN, currentPermission={currentPermission}
currentPermission onUpdate={(newPermission) =>
)) || setCurrentPermission(newPermission)
(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>
))} ))}
</div> </div>
</div> </div>

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

@ -5,28 +5,28 @@
"components.Discover.popularmovies": "Beliebte Filme", "components.Discover.popularmovies": "Beliebte Filme",
"components.Discover.populartv": "Beliebte Serien", "components.Discover.populartv": "Beliebte Serien",
"components.Discover.recentlyAdded": "Kürzlich hinzugefügt", "components.Discover.recentlyAdded": "Kürzlich hinzugefügt",
"components.Discover.recentrequests": "Aktuelle Anfragen", "components.Discover.recentrequests": "Kürzliche Anfragen",
"components.Discover.trending": "Trends", "components.Discover.trending": "Trends",
"components.Discover.upcoming": "Bald erscheinende Filme", "components.Discover.upcoming": "Bald erscheinende Filme",
"components.Discover.upcomingmovies": "Bald erscheinende Filme", "components.Discover.upcomingmovies": "Bald erscheinende Filme",
"components.Layout.LanguagePicker.changelanguage": "Sprache ändern", "components.Layout.LanguagePicker.changelanguage": "Sprache ändern",
"components.Layout.SearchInput.searchPlaceholder": "Nach Filmen und Serien suchen", "components.Layout.SearchInput.searchPlaceholder": "Nach Filmen und Serien suchen",
"components.Layout.Sidebar.dashboard": "Entdecken", "components.Layout.Sidebar.dashboard": "Entdecken",
"components.Layout.Sidebar.requests": "Anträge", "components.Layout.Sidebar.requests": "Anfragen",
"components.Layout.Sidebar.settings": "Einstellungen", "components.Layout.Sidebar.settings": "Einstellungen",
"components.Layout.Sidebar.users": "Benutzer", "components.Layout.Sidebar.users": "Benutzer",
"components.Layout.UserDropdown.signout": "Abmelden", "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.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": "Melden Sie sich an, um fortzufahren", "components.Login.signinplex": "Melde dich an, um fortzufahren",
"components.MovieDetails.approve": "Genehmigen", "components.MovieDetails.approve": "Genehmigen",
"components.MovieDetails.available": "Verfügbar", "components.MovieDetails.available": "Verfügbar",
"components.MovieDetails.budget": "Budget", "components.MovieDetails.budget": "Budget",
"components.MovieDetails.cancelrequest": "Anfrage zurücknehmen", "components.MovieDetails.cancelrequest": "Anfrage abbrechen",
"components.MovieDetails.cast": "Besetzung", "components.MovieDetails.cast": "Besetzung",
"components.MovieDetails.decline": "Ablehnen", "components.MovieDetails.decline": "Ablehnen",
"components.MovieDetails.manageModalClearMedia": "Alle Mediendaten löschen", "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.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.manageModalRequests": "Anfragen",
"components.MovieDetails.manageModalTitle": "Film verwalten", "components.MovieDetails.manageModalTitle": "Film verwalten",
"components.MovieDetails.originallanguage": "Originalsprache", "components.MovieDetails.originallanguage": "Originalsprache",
@ -36,7 +36,7 @@
"components.MovieDetails.recommendations": "Empfehlungen", "components.MovieDetails.recommendations": "Empfehlungen",
"components.MovieDetails.recommendationssubtext": "Wenn dir {title} gefallen hat, könnte dir auch gefallen …", "components.MovieDetails.recommendationssubtext": "Wenn dir {title} gefallen hat, könnte dir auch gefallen …",
"components.MovieDetails.releasedate": "Erscheinungsdatum", "components.MovieDetails.releasedate": "Erscheinungsdatum",
"components.MovieDetails.request": "Anfrage", "components.MovieDetails.request": "Anfragen",
"components.MovieDetails.revenue": "Einnahmen", "components.MovieDetails.revenue": "Einnahmen",
"components.MovieDetails.runtime": "{minutes} Minuten", "components.MovieDetails.runtime": "{minutes} Minuten",
"components.MovieDetails.similar": "Ähnliche Titel", "components.MovieDetails.similar": "Ähnliche Titel",
@ -53,7 +53,7 @@
"components.PlexLoginButton.loginwithplex": "Anmeldung mit Plex", "components.PlexLoginButton.loginwithplex": "Anmeldung mit Plex",
"components.RequestBlock.seasons": "Staffeln", "components.RequestBlock.seasons": "Staffeln",
"components.RequestCard.all": "Alle", "components.RequestCard.all": "Alle",
"components.RequestCard.requestedby": "Anfrage von {username}", "components.RequestCard.requestedby": "Angefragt von {username}",
"components.RequestCard.seasons": "Staffeln", "components.RequestCard.seasons": "Staffeln",
"components.RequestList.RequestItem.notavailable": "entf.", "components.RequestList.RequestItem.notavailable": "entf.",
"components.RequestList.RequestItem.requestedby": "Angefragt von {username}", "components.RequestList.RequestItem.requestedby": "Angefragt von {username}",
@ -64,7 +64,7 @@
"components.RequestList.previous": "Vorherige", "components.RequestList.previous": "Vorherige",
"components.RequestList.requestedAt": "Angefragt am", "components.RequestList.requestedAt": "Angefragt am",
"components.RequestList.requests": "Anfragen", "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.RequestList.status": "Status",
"components.RequestModal.cancel": "Anfrage abbrechen", "components.RequestModal.cancel": "Anfrage abbrechen",
"components.RequestModal.cancelling": "Abbrechen …", "components.RequestModal.cancelling": "Abbrechen …",
@ -77,10 +77,10 @@
"components.RequestModal.request": "Anfrage", "components.RequestModal.request": "Anfrage",
"components.RequestModal.requestCancel": "Anfrage für <strong>{title}</strong> abgebrochen", "components.RequestModal.requestCancel": "Anfrage für <strong>{title}</strong> abgebrochen",
"components.RequestModal.requestSuccess": "<strong>{title}</strong> angefragt.", "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.requestfrom": "Derzeit steht eine Anfrage von {username} aus",
"components.RequestModal.requesting": "Wird angefragt…", "components.RequestModal.requesting": "Wird angefragt …",
"components.RequestModal.requestseasons": "{seasonCount} {seasonCount, plural, one {Season} other {Seasons}} anfragen", "components.RequestModal.requestseasons": "{seasonCount} {seasonCount, plural, one {Staffel} other {Staffeln}} anfragen",
"components.RequestModal.requesttitle": "{title} anfragen", "components.RequestModal.requesttitle": "{title} anfragen",
"components.RequestModal.season": "Staffel", "components.RequestModal.season": "Staffel",
"components.RequestModal.seasonnumber": "Staffel {number}", "components.RequestModal.seasonnumber": "Staffel {number}",
@ -96,15 +96,15 @@
"components.Settings.Notifications.saving": "Speichern …", "components.Settings.Notifications.saving": "Speichern …",
"components.Settings.Notifications.smtpHost": "SMTP-Host", "components.Settings.Notifications.smtpHost": "SMTP-Host",
"components.Settings.Notifications.smtpPort": "SMTP-Port", "components.Settings.Notifications.smtpPort": "SMTP-Port",
"components.Settings.Notifications.validationFromRequired": "Sie müssen eine E-Mail-Absenderadresse angeben", "components.Settings.Notifications.validationFromRequired": "Du musst eine E-Mail-Absenderadresse angeben",
"components.Settings.Notifications.validationSmtpHostRequired": "Sie müssen einen SMTP-Host bereitstellen", "components.Settings.Notifications.validationSmtpHostRequired": "Du musst einen SMTP-Host bereitstellen",
"components.Settings.Notifications.validationSmtpPortRequired": "Sie müssen einen SMTP-Port bereitstellen", "components.Settings.Notifications.validationSmtpPortRequired": "Du musst einen SMTP-Port bereitstellen",
"components.Settings.Notifications.validationWebhookUrlRequired": "Sie müssen eine Webhook-URL angeben", "components.Settings.Notifications.validationWebhookUrlRequired": "Du musst eine Webhook-URL angeben",
"components.Settings.Notifications.webhookUrl": "Webhook-URL", "components.Settings.Notifications.webhookUrl": "Webhook-URL",
"components.Settings.Notifications.webhookUrlPlaceholder": "Servereinstellungen -> Integrationen -> Webhooks", "components.Settings.Notifications.webhookUrlPlaceholder": "Servereinstellungen -> Integrationen -> Webhooks",
"components.Settings.RadarrModal.add": "Server hinzufügen", "components.Settings.RadarrModal.add": "Server hinzufügen",
"components.Settings.RadarrModal.apiKey": "API-Schlüssel", "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.baseUrl": "Basis-URL",
"components.Settings.RadarrModal.baseUrlPlaceholder": "Beispiel: /radarr", "components.Settings.RadarrModal.baseUrlPlaceholder": "Beispiel: /radarr",
"components.Settings.RadarrModal.createradarr": "Einen neuen Radarr-Server erstellen", "components.Settings.RadarrModal.createradarr": "Einen neuen Radarr-Server erstellen",
@ -117,9 +117,9 @@
"components.Settings.RadarrModal.rootfolder": "Stammordner", "components.Settings.RadarrModal.rootfolder": "Stammordner",
"components.Settings.RadarrModal.save": "Änderungen speichern", "components.Settings.RadarrModal.save": "Änderungen speichern",
"components.Settings.RadarrModal.saving": "Speichern …", "components.Settings.RadarrModal.saving": "Speichern …",
"components.Settings.RadarrModal.selectMinimumAvailability": "Wählen Sie die Mindestverfügbarkeit", "components.Settings.RadarrModal.selectMinimumAvailability": "Wähle die Mindestverfügbarkeit",
"components.Settings.RadarrModal.selectQualityProfile": "Wählen Sie ein Qualitätsprofil", "components.Settings.RadarrModal.selectQualityProfile": "Wähle ein Qualitätsprofil",
"components.Settings.RadarrModal.selectRootFolder": "Wählen Sie einen Stammordner", "components.Settings.RadarrModal.selectRootFolder": "Wähle einen Stammordner",
"components.Settings.RadarrModal.server4k": "4K-Server", "components.Settings.RadarrModal.server4k": "4K-Server",
"components.Settings.RadarrModal.servername": "Servername", "components.Settings.RadarrModal.servername": "Servername",
"components.Settings.RadarrModal.servernamePlaceholder": "Ein Radarr-Server", "components.Settings.RadarrModal.servernamePlaceholder": "Ein Radarr-Server",
@ -128,14 +128,14 @@
"components.Settings.RadarrModal.testing": "Testen …", "components.Settings.RadarrModal.testing": "Testen …",
"components.Settings.RadarrModal.toastRadarrTestFailure": "Verbindung zum Radarr-Server fehlgeschlagen", "components.Settings.RadarrModal.toastRadarrTestFailure": "Verbindung zum Radarr-Server fehlgeschlagen",
"components.Settings.RadarrModal.toastRadarrTestSuccess": "Radarr-Verbindung hergestellt!", "components.Settings.RadarrModal.toastRadarrTestSuccess": "Radarr-Verbindung hergestellt!",
"components.Settings.RadarrModal.validationApiKeyRequired": "Sie müssen einen API-Schlüssel angeben", "components.Settings.RadarrModal.validationApiKeyRequired": "Du musst einen API-Schlüssel angeben",
"components.Settings.RadarrModal.validationHostnameRequired": "Sie müssen einen Hostnamen/IP angeben", "components.Settings.RadarrModal.validationHostnameRequired": "Du musst einen Hostnamen/IP angeben",
"components.Settings.RadarrModal.validationPortRequired": "Sie müssen einen Port angeben", "components.Settings.RadarrModal.validationPortRequired": "Du musst einen Port angeben",
"components.Settings.RadarrModal.validationProfileRequired": "Sie müssen ein Profil auswählen", "components.Settings.RadarrModal.validationProfileRequired": "Du musst ein Profil auswählen",
"components.Settings.RadarrModal.validationRootFolderRequired": "Sie müssen einen Stammordner auswählen", "components.Settings.RadarrModal.validationRootFolderRequired": "Du musst einen Stammordner auswählen",
"components.Settings.SonarrModal.add": "Server hinzufügen", "components.Settings.SonarrModal.add": "Server hinzufügen",
"components.Settings.SonarrModal.apiKey": "API-Schlüssel", "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.baseUrl": "Basis-URL",
"components.Settings.SonarrModal.baseUrlPlaceholder": "Beispiel: /sonarr", "components.Settings.SonarrModal.baseUrlPlaceholder": "Beispiel: /sonarr",
"components.Settings.SonarrModal.createsonarr": "Neuen Sonarr-Server erstellen", "components.Settings.SonarrModal.createsonarr": "Neuen Sonarr-Server erstellen",
@ -147,22 +147,22 @@
"components.Settings.SonarrModal.rootfolder": "Stammordner", "components.Settings.SonarrModal.rootfolder": "Stammordner",
"components.Settings.SonarrModal.save": "Änderungen speichern", "components.Settings.SonarrModal.save": "Änderungen speichern",
"components.Settings.SonarrModal.saving": "Speichern …", "components.Settings.SonarrModal.saving": "Speichern …",
"components.Settings.SonarrModal.seasonfolders": "Staffelnordner", "components.Settings.SonarrModal.seasonfolders": "Staffel Ordner",
"components.Settings.SonarrModal.selectQualityProfile": "Wählen Sie ein Qualitätsprofil", "components.Settings.SonarrModal.selectQualityProfile": "Wähle ein Qualitätsprofil",
"components.Settings.SonarrModal.selectRootFolder": "Wählen Sie einen Stammordner", "components.Settings.SonarrModal.selectRootFolder": "Wähle einen Stammordner",
"components.Settings.SonarrModal.server4k": "4K-Server", "components.Settings.SonarrModal.server4k": "4K-Server",
"components.Settings.SonarrModal.servername": "Servername", "components.Settings.SonarrModal.servername": "Servername",
"components.Settings.SonarrModal.servernamePlaceholder": "Ein Sonarr-Server", "components.Settings.SonarrModal.servernamePlaceholder": "Ein Sonarr-Server",
"components.Settings.SonarrModal.ssl": "SSL", "components.Settings.SonarrModal.ssl": "SSL",
"components.Settings.SonarrModal.test": "Test", "components.Settings.SonarrModal.test": "Test",
"components.Settings.SonarrModal.testing": "Testen …", "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.toastRadarrTestSuccess": "Sonarr-Verbindung hergestellt!",
"components.Settings.SonarrModal.validationApiKeyRequired": "Sie müssen einen API-Schlüssel angeben", "components.Settings.SonarrModal.validationApiKeyRequired": "Du musst einen API-Schlüssel angeben",
"components.Settings.SonarrModal.validationHostnameRequired": "Sie müssen einen Hostnamen/IP angeben", "components.Settings.SonarrModal.validationHostnameRequired": "Du musst einen Hostnamen/IP angeben",
"components.Settings.SonarrModal.validationPortRequired": "Sie müssen einen Port angeben", "components.Settings.SonarrModal.validationPortRequired": "Du musst einen Port angeben",
"components.Settings.SonarrModal.validationProfileRequired": "Sie müssen ein Profil auswählen", "components.Settings.SonarrModal.validationProfileRequired": "Du musst ein Profil auswählen",
"components.Settings.SonarrModal.validationRootFolderRequired": "Sie müssen einen Stammordner auswählen", "components.Settings.SonarrModal.validationRootFolderRequired": "Du musst einen Stammordner auswählen",
"components.Settings.activeProfile": "Aktives Profil", "components.Settings.activeProfile": "Aktives Profil",
"components.Settings.addradarr": "Radarr-Server hinzufügen", "components.Settings.addradarr": "Radarr-Server hinzufügen",
"components.Settings.address": "Adresse", "components.Settings.address": "Adresse",
@ -170,44 +170,44 @@
"components.Settings.apikey": "API-Schlüssel", "components.Settings.apikey": "API-Schlüssel",
"components.Settings.applicationurl": "Anwendungs-URL", "components.Settings.applicationurl": "Anwendungs-URL",
"components.Settings.cancelscan": "Scan abbrechen", "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.currentlibrary": "Aktuelle Bibliothek: {name}",
"components.Settings.default": "Standardmäßig", "components.Settings.default": "Standardmäßig",
"components.Settings.default4k": "Standard-4K", "components.Settings.default4k": "Standard-4K",
"components.Settings.delete": "Löschen", "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.edit": "Bearbeiten",
"components.Settings.generalsettings": "Allgemeine Einstellungen", "components.Settings.generalsettings": "Allgemeine Einstellungen",
"components.Settings.generalsettingsDescription": "Dies sind Einstellungen, die sich auf die allgemeine Overseerr-Konfiguration beziehen.", "components.Settings.generalsettingsDescription": "Dies sind Einstellungen, die sich auf die allgemeine Overseerr-Konfiguration beziehen.",
"components.Settings.hostname": "Hostname/IP", "components.Settings.hostname": "Hostname/IP",
"components.Settings.jobname": "Auftragsname", "components.Settings.jobname": "Aufgabenname",
"components.Settings.librariesRemaining": "Verbleibende Bibliotheken: {count}", "components.Settings.librariesRemaining": "Verbleibende Bibliotheken: {count}",
"components.Settings.manualscan": "Manueller Bibliotheksscan", "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.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.menuAbout": "Über",
"components.Settings.menuGeneralSettings": "Allgemeine Einstellungen", "components.Settings.menuGeneralSettings": "Allgemeine Einstellungen",
"components.Settings.menuJobs": "Anträge", "components.Settings.menuJobs": "Aufgaben",
"components.Settings.menuLogs": "Protokolle", "components.Settings.menuLogs": "Protokolle",
"components.Settings.menuNotifications": "Benachrichtigungen", "components.Settings.menuNotifications": "Benachrichtigungen",
"components.Settings.menuPlexSettings": "Plex", "components.Settings.menuPlexSettings": "Plex",
"components.Settings.menuServices": "Dienste", "components.Settings.menuServices": "Dienste",
"components.Settings.nextexecution": "Nächste Ausführung", "components.Settings.nextexecution": "Nächste Ausführung",
"components.Settings.notificationsettings": "Benachrichtigungseinstellungen", "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.notrunning": "Nicht aktiv",
"components.Settings.plexlibraries": "Plex-Bibliotheken", "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.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.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.radarrsettings": "Radarr-Einstellungen",
"components.Settings.runnow": "Jetzt ausführen", "components.Settings.runnow": "Jetzt ausführen",
"components.Settings.save": "Änderungen speichern", "components.Settings.save": "Änderungen speichern",
"components.Settings.saving": "Speichern …", "components.Settings.saving": "Speichern …",
"components.Settings.servername": "Servername (Wird nach dem Speichern automatisch festgelegt)", "components.Settings.servername": "Servername (Wird nach dem Speichern automatisch festgelegt)",
"components.Settings.servernamePlaceholder": "Plex-Servername", "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.sonarrsettings": "Sonarr-Einstellungen",
"components.Settings.ssl": "SSL", "components.Settings.ssl": "SSL",
"components.Settings.startscan": "Scan starten", "components.Settings.startscan": "Scan starten",
@ -219,69 +219,69 @@
"components.Setup.finish": "Konfiguration beenden", "components.Setup.finish": "Konfiguration beenden",
"components.Setup.finishing": "Fertigstellung …", "components.Setup.finishing": "Fertigstellung …",
"components.Setup.loginwithplex": "Anmeldung mit Plex", "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.Setup.welcome": "Willkommen bei Overseerr",
"components.Slider.noresults": "Keine Ergebnisse", "components.Slider.noresults": "Keine Ergebnisse",
"components.TitleCard.movie": "Film", "components.TitleCard.movie": "Film",
"components.TitleCard.tvshow": "Serie", "components.TitleCard.tvshow": "Serie",
"components.TvDetails.approve": "Genehmigen", "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.available": "Verfügbar",
"components.TvDetails.cancelrequest": "Antrag abbrechen", "components.TvDetails.cancelrequest": "Anfrage abbrechen",
"components.TvDetails.cast": "Besetzung", "components.TvDetails.cast": "Besetzung",
"components.TvDetails.decline": "Ablehnen", "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.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.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 Anträge", "components.TvDetails.manageModalNoRequests": "Keine Anfragen",
"components.TvDetails.manageModalRequests": "Anträge", "components.TvDetails.manageModalRequests": "Anfragen",
"components.TvDetails.manageModalTitle": "Serien verwalten", "components.TvDetails.manageModalTitle": "Serie verwalten",
"components.TvDetails.originallanguage": "Originalsprache", "components.TvDetails.originallanguage": "Originalsprache",
"components.TvDetails.overview": "Übersicht", "components.TvDetails.overview": "Übersicht",
"components.TvDetails.overviewunavailable": "Übersicht nicht verfügbar", "components.TvDetails.overviewunavailable": "Übersicht nicht verfügbar",
"components.TvDetails.pending": "Ausstehend", "components.TvDetails.pending": "Ausstehend",
"components.TvDetails.recommendations": "Empfehlungen", "components.TvDetails.recommendations": "Empfehlungen",
"components.TvDetails.recommendationssubtext": "Wenn Ihnen {title} gefallen hat, könnte Ihnen auch gefallen …", "components.TvDetails.recommendationssubtext": "Wenn dir {title} gefallen hat, könnte dir auch gefallen …",
"components.TvDetails.request": "Antrag", "components.TvDetails.request": "Anfragen",
"components.TvDetails.requestmore": "Mehr anfordern", "components.TvDetails.requestmore": "Mehr anfragen",
"components.TvDetails.similar": "Ähnliche Serien", "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.status": "Status",
"components.TvDetails.unavailable": "Nicht verfügbar", "components.TvDetails.unavailable": "Nicht verfügbar",
"components.TvDetails.userrating": "Benutzerbewertung", "components.TvDetails.userrating": "Benutzerbewertung",
"components.UserEdit.admin": "Administrator/in", "components.UserEdit.admin": "Administrator",
"components.UserEdit.adminDescription": "Voller Administratorzugriff. Umgeht alle Berechtigungsprüfungen.", "components.UserEdit.adminDescription": "Voller Administratorzugriff. Umgeht alle Berechtigungsprüfungen.",
"components.UserEdit.autoapprove": "Automatische Genehmigung", "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.avatar": "Avatar",
"components.UserEdit.edituser": "Benutzer/in bearbeiten", "components.UserEdit.edituser": "Benutzer bearbeiten",
"components.UserEdit.email": "E-Mail", "components.UserEdit.email": "E-Mail",
"components.UserEdit.managerequests": "Anträge verwalten", "components.UserEdit.managerequests": "Anfragen verwalten",
"components.UserEdit.managerequestsDescription": "Erteilt die Berechtigung zum Verwalten von Overseerr-Anträgen. Dies umfasst das Genehmigen und Ablehnen von Anträgen.", "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.permissions": "Berechtigungen",
"components.UserEdit.request": "Antrag", "components.UserEdit.request": "Anfragen",
"components.UserEdit.requestDescription": "Erteilt die Berechtigung zum Anfordern von Filmen und Serien.", "components.UserEdit.requestDescription": "Erteilt die Berechtigung zum Anfragen von Filmen und Serien.",
"components.UserEdit.save": "Speichern", "components.UserEdit.save": "Speichern",
"components.UserEdit.saving": "Speichern …", "components.UserEdit.saving": "Speichern …",
"components.UserEdit.settings": "Einstellungen verwalten", "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.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 ein Fehler aufgetreten.", "components.UserEdit.userfail": "Beim Speichern des Benutzers ist etwas schief gelaufen.",
"components.UserEdit.username": "Benutzername", "components.UserEdit.username": "Benutzername",
"components.UserEdit.users": "Benutzer verwalten", "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.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/in gespeichert", "components.UserEdit.usersaved": "Benutzer gespeichert",
"components.UserEdit.vote": "Abstimmen", "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.admin": "Admin",
"components.UserList.created": "Erstellt", "components.UserList.created": "Erstellt",
"components.UserList.delete": "Löschen", "components.UserList.delete": "Löschen",
"components.UserList.edit": "Bearbeiten", "components.UserList.edit": "Bearbeiten",
"components.UserList.lastupdated": "Zuletzt aktualisiert", "components.UserList.lastupdated": "Zuletzt aktualisiert",
"components.UserList.plexuser": "Plex-Benutzer/in", "components.UserList.plexuser": "Plex-Benutzer",
"components.UserList.role": "Rolle", "components.UserList.role": "Rolle",
"components.UserList.totalrequests": "Anträge insgesamt", "components.UserList.totalrequests": "Anfragen insgesamt",
"components.UserList.user": "Benutzer/in", "components.UserList.user": "Benutzer",
"components.UserList.userlist": "Benutzer/innen-Liste", "components.UserList.userlist": "Benutzer-Liste",
"components.UserList.username": "Benutzername", "components.UserList.username": "Benutzername",
"components.UserList.usertype": "Benutzertyp", "components.UserList.usertype": "Benutzertyp",
"i18n.approve": "Genehmigen", "i18n.approve": "Genehmigen",
@ -291,10 +291,10 @@
"i18n.decline": "Ablehnen", "i18n.decline": "Ablehnen",
"i18n.declined": "Abgelehnt", "i18n.declined": "Abgelehnt",
"i18n.delete": "Löschen", "i18n.delete": "Löschen",
"i18n.movies": "Films", "i18n.movies": "Filme",
"i18n.partiallyavailable": "Teilweise verfügbar", "i18n.partiallyavailable": "Teilweise verfügbar",
"i18n.pending": "Ausstehend", "i18n.pending": "Ausstehend",
"i18n.processing": "Verarbeitung …", "i18n.processing": "Verarbeiten …",
"i18n.tvshows": "Serien", "i18n.tvshows": "Serien",
"i18n.unavailable": "Nicht verfügbar", "i18n.unavailable": "Nicht verfügbar",
"pages.internalServerError": "{statusCode} Interner Serverfehler", "pages.internalServerError": "{statusCode} Interner Serverfehler",
@ -302,62 +302,62 @@
"pages.pageNotFound": "404 Seite nicht gefunden", "pages.pageNotFound": "404 Seite nicht gefunden",
"pages.returnHome": "Zur Startseite", "pages.returnHome": "Zur Startseite",
"pages.serviceUnavailable": "{statusCode} Dienst nicht verfügbar", "pages.serviceUnavailable": "{statusCode} Dienst nicht verfügbar",
"pages.somethingWentWrong": "{statusCode} Es ist ein Fehler aufgetreten", "pages.somethingWentWrong": "{statusCode} Es ist etwas schief gelaufen",
"components.TvDetails.TvCast.fullseriescast": "Vollserienbesetzung", "components.TvDetails.TvCast.fullseriescast": "Komplette Serien Besetzung",
"components.MovieDetails.MovieCast.fullcast": "Vollständige Besetzung", "components.MovieDetails.MovieCast.fullcast": "Komplette Besetzung",
"components.Settings.Notifications.emailsettingssaved": "E-Mail-Benachrichtigungseinstellungen gespeichert!", "components.Settings.Notifications.emailsettingssaved": "E-Mail-Benachrichtigungseinstellungen gespeichert!",
"components.Settings.Notifications.emailsettingsfailed": "Fehler beim Speichern der E-Mail-Benachrichtigungseinstellungen.", "components.Settings.Notifications.emailsettingsfailed": "Fehler beim Speichern der E-Mail-Benachrichtigungseinstellungen.",
"components.Settings.Notifications.discordsettingssaved": "Discord-Benachrichtigungseinstellungen gespeichert!", "components.Settings.Notifications.discordsettingssaved": "Discord-Benachrichtigungseinstellungen gespeichert!",
"components.Settings.Notifications.discordsettingsfailed": "Fehler beim Speichern der Discord-Benachrichtigungseinstellungen.", "components.Settings.Notifications.discordsettingsfailed": "Fehler beim Speichern der Discord-Benachrichtigungseinstellungen.",
"components.Settings.validationPortRequired": "Sie müssen einen Port angeben", "components.Settings.validationPortRequired": "Du musst einen Port angeben",
"components.Settings.validationHostnameRequired": "Sie müssen einen Hostnamen/IP angeben", "components.Settings.validationHostnameRequired": "Du musst einen Hostnamen/IP angeben",
"components.Settings.SonarrModal.validationNameRequired": "Sie müssen einen Servernamen angeben", "components.Settings.SonarrModal.validationNameRequired": "Du musst einen Servernamen angeben",
"components.Settings.SettingsAbout.version": "Version", "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.totalmedia": "Medien insgesamt",
"components.Settings.SettingsAbout.overseerrinformation": "Overseerr-Informationen", "components.Settings.SettingsAbout.overseerrinformation": "Overseerr-Informationen",
"components.Settings.SettingsAbout.githubdiscussions": "GitHub-Diskussionen", "components.Settings.SettingsAbout.githubdiscussions": "GitHub-Diskussionen",
"components.Settings.SettingsAbout.gettingsupport": "Hilfe erhalten", "components.Settings.SettingsAbout.gettingsupport": "Hilfe erhalten",
"components.Settings.SettingsAbout.clickheretojoindiscord": "Klicken Sie hier, um unserem Discord-Server beizutreten.", "components.Settings.SettingsAbout.clickheretojoindiscord": "Klicke hier, um unserem Discord-Server beizutreten.",
"components.Settings.RadarrModal.validationNameRequired": "Sie müssen einen Servernamen angeben", "components.Settings.RadarrModal.validationNameRequired": "Du musst einen Servernamen angeben",
"components.Setup.tip": "Tipp", "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 …", "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.userdeleted": "Benutzer gelöscht",
"components.UserList.deleteuser": "Benutzer löschen", "components.UserList.deleteuser": "Benutzer löschen",
"components.UserList.deleteconfirm": "Wollen Sie diesen Benutzer wirklich löschen? Alle vorhandenen Anforderungsdaten dieses Benutzers werden entfernt.", "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 Anforderungen an Ihre Dienste weitergeleitet werden.", "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.nodefault": "Kein Standardserver ausgewählt!",
"components.Settings.no4kimplemented": "(Standard-4K-Server sind derzeit nicht implementiert.)", "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.testFirstRootFolders": "Teste die Verbindung, um Stammordner zu laden",
"components.Settings.SonarrModal.testFirstQualityProfiles": "Testen Sie Ihre Verbindung, um Qualitätsprofile zu laden", "components.Settings.SonarrModal.testFirstQualityProfiles": "Teste die Verbindung, um Qualitätsprofile zu laden",
"components.Settings.SonarrModal.loadingrootfolders": "Laden von Stammordnern…", "components.Settings.SonarrModal.loadingrootfolders": "Stammordner werden geladen …",
"components.Settings.SonarrModal.loadingprofiles": "Qualitätsprofile werden geladen…", "components.Settings.SonarrModal.loadingprofiles": "Qualitätsprofile werden geladen …",
"components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "Sie müssen die Mindestverfügbarkeit auswählen", "components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "Du musst die Mindestverfügbarkeit auswählen",
"components.Settings.RadarrModal.testFirstRootFolders": "Testen Sie Ihre Verbindung, um Stammordner zu laden", "components.Settings.RadarrModal.testFirstRootFolders": "Teste die Verbindung, um Stammordner zu laden",
"components.Settings.RadarrModal.testFirstQualityProfiles": "Testen Sie Ihre Verbindung zu Lastqualitätsprofilen", "components.Settings.RadarrModal.testFirstQualityProfiles": "Teste die Verbindung, um Qualitätsprofile zu laden",
"components.Settings.RadarrModal.loadingrootfolders": "Laden von Stammordnern…", "components.Settings.RadarrModal.loadingrootfolders": "Stammordner werden geladen …",
"components.Settings.RadarrModal.loadingprofiles": "Qualitätsprofile werden geladen…", "components.Settings.RadarrModal.loadingprofiles": "Qualitätsprofile werden geladen …",
"components.TvDetails.anime": "Anime", "components.TvDetails.anime": "Anime",
"components.Settings.toastApiKeySuccess": "Neuer API-Schlüssel generiert!", "components.Settings.toastApiKeySuccess": "Neuer API-Schlüssel generiert!",
"components.TvDetails.showtype": "Serientyp", "components.TvDetails.showtype": "Serientyp",
"components.TvDetails.network": "Netzwerk", "components.TvDetails.network": "Netzwerk",
"components.Settings.toastSettingsSuccess": "Einstellungen gespeichert.", "components.Settings.toastSettingsSuccess": "Einstellungen gespeichert.",
"components.Settings.toastSettingsFailure": "Beim Speichern der Einstellungen ging etwas schief.", "components.Settings.toastSettingsFailure": "Beim Speichern der Einstellungen ist etwas schief gelaufen.",
"components.Settings.toastApiKeyFailure": "Bei der Generierung eines neuen API-Schlüssels kam es zu einem Fehler.", "components.Settings.toastApiKeyFailure": "Bei der Generierung eines neuen API-Schlüssels ist etwas schief gelaufen.",
"components.Settings.SonarrModal.animerootfolder": "Animestammverzeichnis", "components.Settings.SonarrModal.animerootfolder": "Animestammverzeichnis",
"components.Settings.SonarrModal.animequalityprofile": "Animequalitätsprofil", "components.Settings.SonarrModal.animequalityprofile": "Animequalitätsprofil",
"components.MovieDetails.studio": "Studio", "components.MovieDetails.studio": "Studio",
"i18n.close": "Schließen", "i18n.close": "Schließen",
"components.Settings.SettingsAbout.timezone": "Zeitzone", "components.Settings.SettingsAbout.timezone": "Zeitzone",
"components.Settings.SettingsAbout.supportoverseerr": "Unterstützen Sie Overseerr", "components.Settings.SettingsAbout.supportoverseerr": "Unterstütze Overseerr",
"components.Settings.SettingsAbout.helppaycoffee": "Helfen Sie uns, für Kaffee zu bezahlen", "components.Settings.SettingsAbout.helppaycoffee": "Hilf uns Kaffee zu bezahlen",
"components.Settings.SettingsAbout.Releases.viewongithub": "Ansicht auf GitHub", "components.Settings.SettingsAbout.Releases.viewongithub": "Auf GitHub anzeigen",
"components.Settings.SettingsAbout.Releases.viewchangelog": "Änderungsprotokoll anzeigen", "components.Settings.SettingsAbout.Releases.viewchangelog": "Änderungsprotokoll anzeigen",
"components.Settings.SettingsAbout.Releases.versionChangelog": "Änderungsprotokoll", "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.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": "Sie führen eine Entwicklungsversion von Overseerr aus!", "components.Settings.SettingsAbout.Releases.runningDevelop": "Du führst eine Entwicklungsversion von Overseerr aus!",
"components.Settings.SettingsAbout.Releases.releases": "Veröffentlichungen", "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.releasedataMissing": "Informationen der Version nicht verfügbar. Ist GitHub offline?",
"components.Settings.SettingsAbout.Releases.latestversion": "Neuste Version", "components.Settings.SettingsAbout.Releases.latestversion": "Neuste Version",
@ -365,15 +365,31 @@
"components.Settings.Notifications.testsent": "Testbenachrichtigung gesendet!", "components.Settings.Notifications.testsent": "Testbenachrichtigung gesendet!",
"components.Settings.Notifications.test": "Test", "components.Settings.Notifications.test": "Test",
"components.Settings.defaultPermissions": "Standardbenutzerberechtigungen", "components.Settings.defaultPermissions": "Standardbenutzerberechtigungen",
"components.UserList.importfromplexerror": "Beim Importieren von Benutzern aus Plex ist ein Fehler aufgetreten", "components.UserList.importfromplexerror": "Beim Importieren von Benutzern aus Plex ist etwas schief gelaufen",
"components.UserList.importfromplex": "Benutzer/innen aus Plex importieren", "components.UserList.importfromplex": "Benutzer aus Plex importieren",
"components.TvDetails.viewfullcrew": "Volles Team anzeigen", "components.TvDetails.viewfullcrew": "Komplettes Team anzeigen",
"components.TvDetails.TvCrew.fullseriescrew": "Volles Serienteam", "components.TvDetails.TvCrew.fullseriescrew": "Komplettes Serien Team",
"components.PersonDetails.crewmember": "Teammitglied", "components.PersonDetails.crewmember": "Teammitglied",
"components.MovieDetails.viewfullcrew": "Volles Team anzeigen", "components.MovieDetails.viewfullcrew": "Komplettes Team anzeigen",
"components.MovieDetails.MovieCrew.fullcrew": "Volles Team", "components.MovieDetails.MovieCrew.fullcrew": "Komplettes Team",
"components.UserList.importedfromplex": "{userCount, plural, =0 {Keine neue Benutzer} one {# neuer Benutzer} other {# neue Benutzer}} aus Plex importiert", "components.UserList.importedfromplex": "{userCount, plural, =0 {Keine neuen Benutzer} one {# neuer Benutzer} other {# neue Benutzer}} aus Plex importiert",
"components.TvDetails.firstAirDate": "Erster Sendetermin", "components.TvDetails.firstAirDate": "Erstausstrahlung",
"components.Settings.Notifications.ssldisabletip": "SSL sollte bei Standard-TLS-Verbindungen deaktiviert werden (Port 587)", "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.discovermovies": "Popular Movies",
"components.Discover.discovertv": "Popular Series", "components.Discover.discovertv": "Popular Series",
"components.Discover.nopending": "No Pending Requests", "components.Discover.nopending": "No Pending Requests",
@ -47,8 +56,10 @@
"components.MovieDetails.studio": "Studio", "components.MovieDetails.studio": "Studio",
"components.MovieDetails.unavailable": "Unavailable", "components.MovieDetails.unavailable": "Unavailable",
"components.MovieDetails.userrating": "User Rating", "components.MovieDetails.userrating": "User Rating",
"components.MovieDetails.view": "View",
"components.MovieDetails.viewfullcrew": "View Full Crew", "components.MovieDetails.viewfullcrew": "View Full Crew",
"components.MovieDetails.viewrequest": "View Request", "components.MovieDetails.viewrequest": "View Request",
"components.MovieDetails.watchtrailer": "Watch Trailer",
"components.PersonDetails.appearsin": "Appears in", "components.PersonDetails.appearsin": "Appears in",
"components.PersonDetails.ascharacter": "as {character}", "components.PersonDetails.ascharacter": "as {character}",
"components.PersonDetails.crewmember": "Crew Member", "components.PersonDetails.crewmember": "Crew Member",
@ -60,6 +71,7 @@
"components.RequestCard.all": "All", "components.RequestCard.all": "All",
"components.RequestCard.requestedby": "Requested by {username}", "components.RequestCard.requestedby": "Requested by {username}",
"components.RequestCard.seasons": "Seasons", "components.RequestCard.seasons": "Seasons",
"components.RequestList.RequestItem.failedretry": "Something went wrong retrying the request",
"components.RequestList.RequestItem.notavailable": "N/A", "components.RequestList.RequestItem.notavailable": "N/A",
"components.RequestList.RequestItem.requestedby": "Requested by {username}", "components.RequestList.RequestItem.requestedby": "Requested by {username}",
"components.RequestList.RequestItem.seasons": "Seasons", "components.RequestList.RequestItem.seasons": "Seasons",
@ -92,6 +104,18 @@
"components.RequestModal.selectseason": "Select season(s)", "components.RequestModal.selectseason": "Select season(s)",
"components.RequestModal.status": "Status", "components.RequestModal.status": "Status",
"components.Search.searchresults": "Search Results", "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.agentenabled": "Agent Enabled",
"components.Settings.Notifications.allowselfsigned": "Allow Self-Signed Certificates", "components.Settings.Notifications.allowselfsigned": "Allow Self-Signed Certificates",
"components.Settings.Notifications.authPass": "Auth Pass", "components.Settings.Notifications.authPass": "Auth Pass",
@ -313,10 +337,15 @@
"components.TvDetails.unavailable": "Unavailable", "components.TvDetails.unavailable": "Unavailable",
"components.TvDetails.userrating": "User Rating", "components.TvDetails.userrating": "User Rating",
"components.TvDetails.viewfullcrew": "View Full Crew", "components.TvDetails.viewfullcrew": "View Full Crew",
"components.TvDetails.watchtrailer": "Watch Trailer",
"components.UserEdit.admin": "Admin", "components.UserEdit.admin": "Admin",
"components.UserEdit.adminDescription": "Full administrator access. Bypasses all permission checks.", "components.UserEdit.adminDescription": "Full administrator access. Bypasses all permission checks.",
"components.UserEdit.autoapprove": "Auto Approve", "components.UserEdit.autoapprove": "Auto Approve",
"components.UserEdit.autoapproveDescription": "Grants auto approval for any requests made by this user.", "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.avatar": "Avatar",
"components.UserEdit.edituser": "Edit User", "components.UserEdit.edituser": "Edit User",
"components.UserEdit.email": "Email", "components.UserEdit.email": "Email",
@ -364,10 +393,13 @@
"i18n.declined": "Declined", "i18n.declined": "Declined",
"i18n.delete": "Delete", "i18n.delete": "Delete",
"i18n.deleting": "Deleting…", "i18n.deleting": "Deleting…",
"i18n.failed": "Failed",
"i18n.movies": "Movies", "i18n.movies": "Movies",
"i18n.partiallyavailable": "Partially Available", "i18n.partiallyavailable": "Partially Available",
"i18n.pending": "Pending", "i18n.pending": "Pending",
"i18n.processing": "Processing…", "i18n.processing": "Processing…",
"i18n.requested": "Requested",
"i18n.retry": "Retry",
"i18n.tvshows": "Series", "i18n.tvshows": "Series",
"i18n.unavailable": "Unavailable", "i18n.unavailable": "Unavailable",
"pages.internalServerError": "{statusCode} - Internal Server Error", "pages.internalServerError": "{statusCode} - Internal Server Error",

@ -304,7 +304,7 @@
"components.Settings.menuJobs": "Tareas", "components.Settings.menuJobs": "Tareas",
"components.Settings.menuGeneralSettings": "Ajustes Generales", "components.Settings.menuGeneralSettings": "Ajustes Generales",
"components.Settings.menuAbout": "Acerca de", "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.manualscan": "Escaneo manual de biblioteca",
"components.Settings.librariesRemaining": "Bibliotecas restantes: {count}", "components.Settings.librariesRemaining": "Bibliotecas restantes: {count}",
"components.Settings.hostname": "Nombre de host / IP", "components.Settings.hostname": "Nombre de host / IP",
@ -364,5 +364,25 @@
"components.Settings.SettingsAbout.Releases.currentversion": "Versión Actual", "components.Settings.SettingsAbout.Releases.currentversion": "Versión Actual",
"components.Settings.Notifications.testsent": "¡Notificación de prueba enviada!", "components.Settings.Notifications.testsent": "¡Notificación de prueba enviada!",
"components.Settings.Notifications.test": "Comprobar", "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.MovieDetails.MovieCrew.fullcrew": "Équipe complète",
"components.TvDetails.firstAirDate": "Date de première diffusion", "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.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.saving": "Salvataggio…",
"components.UserEdit.save": "Salva", "components.UserEdit.save": "Salva",
"components.UserEdit.usersaved": "Utente salvato", "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.users": "Gestisci gli utenti",
"components.UserEdit.username": "Nome utente", "components.UserEdit.username": "Nome utente",
"components.UserList.deleteconfirm": "Eliminare l'utente? Tutti i dati di richiesta esistenti da questo utente verranno rimossi.", "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.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.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.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.request": "Richiesta",
"components.UserEdit.permissions": "Autorizzazioni", "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.managerequests": "Gestisci le richieste",
"components.UserEdit.autoapproveDescription": "Concede l'approvazione automatica per tutte le richieste effettuate da questo utente.", "components.UserEdit.autoapproveDescription": "Concede l'approvazione automatica per tutte le richieste effettuate da questo utente.",
"components.UserEdit.autoapprove": "Approvazione automatica", "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.UserEdit.admin": "Amministratore",
"components.TvDetails.userrating": "Voto pubblico", "components.TvDetails.userrating": "Voto pubblico",
"components.TvDetails.unavailable": "Non disponibile", "components.TvDetails.unavailable": "Non disponibile",
@ -322,7 +322,7 @@
"components.Settings.jobname": "Nome Task", "components.Settings.jobname": "Nome Task",
"components.UserEdit.voteDescription": "Concede il permesso di votare sulle richieste (sistema di voto non acora implementato)", "components.UserEdit.voteDescription": "Concede il permesso di votare sulle richieste (sistema di voto non acora implementato)",
"components.UserEdit.vote": "Vota", "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.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.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", "components.Setup.signinMessage": "Comincia accedendo con il tuo account Plex",
@ -364,7 +364,7 @@
"components.Settings.SettingsAbout.Releases.currentversion": "Versione attuale", "components.Settings.SettingsAbout.Releases.currentversion": "Versione attuale",
"components.Settings.Notifications.testsent": "Notifica di prova inviata!", "components.Settings.Notifications.testsent": "Notifica di prova inviata!",
"components.Settings.Notifications.test": "Test", "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.importfromplexerror": "Qualcosa è andato storto durante l'importazione degli utenti da Plex",
"components.UserList.importfromplex": "Importa 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", "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.MovieCrew.fullcrew": "Troupe completa",
"components.MovieDetails.viewfullcrew": "Vedi 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.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.jobname": "ジョブ名",
"components.Settings.librariesRemaining": "残りのライブラリー: {count}", "components.Settings.librariesRemaining": "残りのライブラリー: {count}",
"components.Settings.manualscan": "手動ライブラリースキャン", "components.Settings.manualscan": "手動ライブラリースキャン",
"components.Settings.manualscanDescription": "通常は6時間に一度しか実行されません。Overseerr は、Plex サーバーの最近追加されたフォルダをより頻繁にチェックします。初めて Plex を設定する場合は、一度手動でライブラリーをスキャンすることをお勧めします。", "components.Settings.manualscanDescription": "通常は24時間に一度しか実行されません。Overseerr は、Plex サーバーの最近追加されたフォルダをより頻繁にチェックします。初めて Plex を設定する場合は、一度手動でライブラリーをスキャンすることをお勧めします。",
"components.Settings.menuAbout": "Overseerrについて", "components.Settings.menuAbout": "Overseerrについて",
"components.Settings.menuGeneralSettings": "一般設定", "components.Settings.menuGeneralSettings": "一般設定",
"components.Settings.menuJobs": "ジョブ", "components.Settings.menuJobs": "ジョブ",
@ -365,5 +365,28 @@
"components.Settings.SettingsAbout.Releases.latestversion": "最新バージョン", "components.Settings.SettingsAbout.Releases.latestversion": "最新バージョン",
"components.Settings.SettingsAbout.Releases.currentversion": "現在のバージョン", "components.Settings.SettingsAbout.Releases.currentversion": "現在のバージョン",
"components.Settings.Notifications.testsent": "テスト通知が送信されました。", "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: dependencies:
flat-cache "^3.0.4" 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: file-uri-to-path@1.0.0:
version "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" 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: dependencies:
moment ">= 2.9.0" moment ">= 2.9.0"
"moment@>= 2.9.0": "moment@>= 2.9.0", moment@^2.11.2:
version "2.29.1" version "2.29.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
@ -9828,7 +9835,7 @@ object-copy@^0.1.0:
define-property "^0.2.5" define-property "^0.2.5"
kind-of "^3.0.3" kind-of "^3.0.3"
object-hash@^2.0.3: object-hash@^2.0.1, object-hash@^2.0.3:
version "2.0.3" version "2.0.3"
resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.0.3.tgz#d12db044e03cd2ca3d77c0570d87225b02e1e6ea" resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.0.3.tgz#d12db044e03cd2ca3d77c0570d87225b02e1e6ea"
integrity sha512-JPKn0GMu+Fa3zt3Bmr66JhokJU5BaNBIh4ZeTlaCBzrBsOeXzwcKKAK1tbLiPKgvwmPXsDvvLHoWh5Bm7ofIYg== integrity sha512-JPKn0GMu+Fa3zt3Bmr66JhokJU5BaNBIh4ZeTlaCBzrBsOeXzwcKKAK1tbLiPKgvwmPXsDvvLHoWh5Bm7ofIYg==
@ -14157,7 +14164,17 @@ widest-line@^3.1.0:
dependencies: dependencies:
string-width "^4.0.0" 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" version "4.4.0"
resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.4.0.tgz#17af518daa690d5b2ecccaa7acf7b20ca7925e59" resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.4.0.tgz#17af518daa690d5b2ecccaa7acf7b20ca7925e59"
integrity sha512-Lc7/p3GtqtqPBYYtS6KCN3c77/2QCev51DvcJKbkFPQNoj1sinkGwLGFDxkXY9J6p9+EPnYs+D90uwbnaiURTw== integrity sha512-Lc7/p3GtqtqPBYYtS6KCN3c77/2QCev51DvcJKbkFPQNoj1sinkGwLGFDxkXY9J6p9+EPnYs+D90uwbnaiURTw==

Loading…
Cancel
Save