Merge branch 'develop'
This commit is contained in:
@@ -124,6 +124,26 @@
|
|||||||
"contributions": [
|
"contributions": [
|
||||||
"code"
|
"code"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "samwiseg0",
|
||||||
|
"name": "samwiseg0",
|
||||||
|
"avatar_url": "https://avatars1.githubusercontent.com/u/2241731?v=4",
|
||||||
|
"profile": "https://github.com/samwiseg0",
|
||||||
|
"contributions": [
|
||||||
|
"question",
|
||||||
|
"infra"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"login": "ecelebi29",
|
||||||
|
"name": "ecelebi29",
|
||||||
|
"avatar_url": "https://avatars2.githubusercontent.com/u/8337120?v=4",
|
||||||
|
"profile": "https://github.com/ecelebi29",
|
||||||
|
"contributions": [
|
||||||
|
"code",
|
||||||
|
"doc"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"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>",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ You can also run the development environment in [Docker](https://www.docker.com/
|
|||||||
- PRs with commits not following this standard will not be merged.
|
- PRs with commits not following this standard will not be merged.
|
||||||
- Please make meaningful commits, or squash them
|
- Please make meaningful commits, or squash them
|
||||||
- Always rebase your commit to the latest `develop` branch. Do not merge develop into your branch.
|
- Always rebase your commit to the latest `develop` branch. Do not merge develop into your branch.
|
||||||
- It is your responsbility to keep your branch up to date. It will not be merged unless its rebased off the latest develop branch.
|
- It is your responsibility to keep your branch up to date. It will not be merged unless its rebased off the latest develop branch.
|
||||||
- You can create a Draft pull request early to get feedback on your work.
|
- You can create a Draft pull request early to get feedback on your work.
|
||||||
- Your code must be formatted correctly or the tests will fail.
|
- Your code must be formatted correctly or the tests will fail.
|
||||||
- We use Prettier to format our codebase. It should auto run with a git hook, but its recommended to have a Prettier extension installed in your editor and have it format on save.
|
- We use Prettier to format our codebase. It should auto run with a git hook, but its recommended to have a Prettier extension installed in your editor and have it format on save.
|
||||||
|
|||||||
@@ -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-13-orange.svg"/></a>
|
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-15-orange.svg"/></a>
|
||||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
- More notification types (Slack/Telegram/etc.).
|
- More notification types (Slack/Telegram/etc.).
|
||||||
- Issues system. This will allow users to report issues with content on your media server.
|
- Issues system. This will allow users to report issues with content on your media server.
|
||||||
- Local user system (for those who don't use Plex).
|
- Local user system (for those who don't use Plex).
|
||||||
- Compatiblity APIs (to work with existing tools in your system).
|
- Compatibility APIs (to work with existing tools in your system).
|
||||||
|
|
||||||
## Running Overseerr
|
## Running Overseerr
|
||||||
|
|
||||||
@@ -114,10 +114,13 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
|||||||
<td align="center"><a href="https://github.com/Shutruk"><img src="https://avatars2.githubusercontent.com/u/9198633?v=4" width="100px;" alt=""/><br /><sub><b>Shutruk</b></sub></a><br /><a href="#translation-Shutruk" title="Translation">🌍</a></td>
|
<td align="center"><a href="https://github.com/Shutruk"><img src="https://avatars2.githubusercontent.com/u/9198633?v=4" width="100px;" alt=""/><br /><sub><b>Shutruk</b></sub></a><br /><a href="#translation-Shutruk" title="Translation">🌍</a></td>
|
||||||
<td align="center"><a href="https://github.com/krystiancharubin"><img src="https://avatars2.githubusercontent.com/u/17775600?v=4" width="100px;" alt=""/><br /><sub><b>Krystian Charubin</b></sub></a><br /><a href="#design-krystiancharubin" title="Design">🎨</a></td>
|
<td align="center"><a href="https://github.com/krystiancharubin"><img src="https://avatars2.githubusercontent.com/u/17775600?v=4" width="100px;" alt=""/><br /><sub><b>Krystian Charubin</b></sub></a><br /><a href="#design-krystiancharubin" title="Design">🎨</a></td>
|
||||||
<td align="center"><a href="https://github.com/kieron"><img src="https://avatars2.githubusercontent.com/u/8655212?v=4" width="100px;" alt=""/><br /><sub><b>Kieron Boswell</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=kieron" title="Code">💻</a></td>
|
<td align="center"><a href="https://github.com/kieron"><img src="https://avatars2.githubusercontent.com/u/8655212?v=4" width="100px;" alt=""/><br /><sub><b>Kieron Boswell</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=kieron" title="Code">💻</a></td>
|
||||||
|
<td align="center"><a href="https://github.com/samwiseg0"><img src="https://avatars1.githubusercontent.com/u/2241731?v=4" width="100px;" alt=""/><br /><sub><b>samwiseg0</b></sub></a><br /><a href="#question-samwiseg0" title="Answering Questions">💬</a> <a href="#infra-samwiseg0" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><a href="https://github.com/ecelebi29"><img src="https://avatars2.githubusercontent.com/u/8337120?v=4" width="100px;" alt=""/><br /><sub><b>ecelebi29</b></sub></a><br /><a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Code">💻</a> <a href="https://github.com/sct/overseerr/commits?author=ecelebi29" title="Documentation">📖</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- markdownlint-enable -->
|
<!-- markdownlint-enable -->
|
||||||
<!-- prettier-ignore-end -->
|
<!-- prettier-ignore-end -->
|
||||||
|
|
||||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const devConfig = {
|
|||||||
logging: false,
|
logging: false,
|
||||||
entities: ['server/entity/**/*.ts'],
|
entities: ['server/entity/**/*.ts'],
|
||||||
migrations: ['server/migration/**/*.ts'],
|
migrations: ['server/migration/**/*.ts'],
|
||||||
|
subscribers: ['server/subscriber/**/*.ts'],
|
||||||
cli: {
|
cli: {
|
||||||
entitiesDir: 'server/entity',
|
entitiesDir: 'server/entity',
|
||||||
migrationsDir: 'server/migration',
|
migrationsDir: 'server/migration',
|
||||||
@@ -19,6 +20,7 @@ const prodConfig = {
|
|||||||
entities: ['dist/entity/**/*.js'],
|
entities: ['dist/entity/**/*.js'],
|
||||||
migrations: ['dist/migration/**/*.js'],
|
migrations: ['dist/migration/**/*.js'],
|
||||||
migrationsRun: true,
|
migrationsRun: true,
|
||||||
|
subscribers: ['dist/subscriber/**/*.js'],
|
||||||
cli: {
|
cli: {
|
||||||
entitiesDir: 'dist/entity',
|
entitiesDir: 'dist/entity',
|
||||||
migrationsDir: 'dist/migration',
|
migrationsDir: 'dist/migration',
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ interface AddSeriesOptions {
|
|||||||
title: string;
|
title: string;
|
||||||
profileId: number;
|
profileId: number;
|
||||||
seasons: number[];
|
seasons: number[];
|
||||||
|
seasonFolder: boolean;
|
||||||
rootFolderPath: string;
|
rootFolderPath: string;
|
||||||
monitored?: boolean;
|
monitored?: boolean;
|
||||||
searchNow?: boolean;
|
searchNow?: boolean;
|
||||||
@@ -149,6 +150,7 @@ class SonarrAPI {
|
|||||||
monitored: false,
|
monitored: false,
|
||||||
}))
|
}))
|
||||||
),
|
),
|
||||||
|
seasonFolder: options.seasonFolder,
|
||||||
monitored: options.monitored,
|
monitored: options.monitored,
|
||||||
rootFolderPath: options.rootFolderPath,
|
rootFolderPath: options.rootFolderPath,
|
||||||
addOptions: {
|
addOptions: {
|
||||||
|
|||||||
@@ -8,14 +8,11 @@ import {
|
|||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
getRepository,
|
getRepository,
|
||||||
In,
|
In,
|
||||||
AfterUpdate,
|
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { MediaRequest } from './MediaRequest';
|
import { MediaRequest } from './MediaRequest';
|
||||||
import { MediaStatus, MediaType } from '../constants/media';
|
import { MediaStatus, MediaType } from '../constants/media';
|
||||||
import logger from '../logger';
|
import logger from '../logger';
|
||||||
import Season from './Season';
|
import Season from './Season';
|
||||||
import notificationManager, { Notification } from '../lib/notifications';
|
|
||||||
import TheMovieDb from '../api/themoviedb';
|
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
class Media {
|
class Media {
|
||||||
@@ -98,32 +95,6 @@ class Media {
|
|||||||
constructor(init?: Partial<Media>) {
|
constructor(init?: Partial<Media>) {
|
||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterUpdate()
|
|
||||||
private async _notifyAvailable() {
|
|
||||||
if (this.status === MediaStatus.AVAILABLE) {
|
|
||||||
if (this.mediaType === MediaType.MOVIE) {
|
|
||||||
const requestRepository = getRepository(MediaRequest);
|
|
||||||
const relatedRequests = await requestRepository.find({
|
|
||||||
where: { media: this },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (relatedRequests.length > 0) {
|
|
||||||
const tmdb = new TheMovieDb();
|
|
||||||
const movie = await tmdb.getMovie({ movieId: this.tmdbId });
|
|
||||||
|
|
||||||
relatedRequests.forEach((request) => {
|
|
||||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
|
||||||
notifyUser: request.requestedBy,
|
|
||||||
subject: movie.title,
|
|
||||||
message: movie.overview,
|
|
||||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Media;
|
export default Media;
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ export class MediaRequest {
|
|||||||
title: series.name,
|
title: series.name,
|
||||||
tvdbid: series.external_ids.tvdb_id,
|
tvdbid: series.external_ids.tvdb_id,
|
||||||
seasons: this.seasons.map((season) => season.seasonNumber),
|
seasons: this.seasons.map((season) => season.seasonNumber),
|
||||||
|
seasonFolder: sonarrSettings.enableSeasonFolders,
|
||||||
monitored: true,
|
monitored: true,
|
||||||
searchNow: true,
|
searchNow: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,15 +5,9 @@ import {
|
|||||||
ManyToOne,
|
ManyToOne,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
AfterInsert,
|
|
||||||
AfterUpdate,
|
|
||||||
getRepository,
|
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { MediaStatus } from '../constants/media';
|
import { MediaStatus } from '../constants/media';
|
||||||
import Media from './Media';
|
import Media from './Media';
|
||||||
import logger from '../logger';
|
|
||||||
import TheMovieDb from '../api/themoviedb';
|
|
||||||
import notificationManager, { Notification } from '../lib/notifications';
|
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
class Season {
|
class Season {
|
||||||
@@ -38,60 +32,6 @@ class Season {
|
|||||||
constructor(init?: Partial<Season>) {
|
constructor(init?: Partial<Season>) {
|
||||||
Object.assign(this, init);
|
Object.assign(this, init);
|
||||||
}
|
}
|
||||||
|
|
||||||
@AfterInsert()
|
|
||||||
@AfterUpdate()
|
|
||||||
private async _sendSeasonAvailableNotification() {
|
|
||||||
if (this.status === MediaStatus.AVAILABLE) {
|
|
||||||
try {
|
|
||||||
const lazyMedia = await this.media;
|
|
||||||
const tmdb = new TheMovieDb();
|
|
||||||
const mediaRepository = getRepository(Media);
|
|
||||||
const media = await mediaRepository.findOneOrFail({
|
|
||||||
where: { id: lazyMedia.id },
|
|
||||||
relations: ['requests'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const availableSeasons = media.seasons.map(
|
|
||||||
(season) => season.seasonNumber
|
|
||||||
);
|
|
||||||
|
|
||||||
const request = media.requests.find(
|
|
||||||
(request) =>
|
|
||||||
// Check if the season is complete AND it contains the current season that was just marked available
|
|
||||||
request.seasons.every((season) =>
|
|
||||||
availableSeasons.includes(season.seasonNumber)
|
|
||||||
) &&
|
|
||||||
request.seasons.some(
|
|
||||||
(season) => season.seasonNumber === this.seasonNumber
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (request) {
|
|
||||||
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
|
|
||||||
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
|
||||||
subject: tv.name,
|
|
||||||
message: tv.overview,
|
|
||||||
notifyUser: request.requestedBy,
|
|
||||||
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
|
||||||
extra: [
|
|
||||||
{
|
|
||||||
name: 'Seasons',
|
|
||||||
value: request.seasons
|
|
||||||
.map((season) => season.seasonNumber)
|
|
||||||
.join(', '),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('Something went wrong sending season available notice', {
|
|
||||||
label: 'Notifications',
|
|
||||||
message: e.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Season;
|
export default Season;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { MediaStatus, MediaType } from '../../constants/media';
|
|||||||
import logger from '../../logger';
|
import logger from '../../logger';
|
||||||
import { getSettings, Library } from '../../lib/settings';
|
import { getSettings, Library } from '../../lib/settings';
|
||||||
import Season from '../../entity/Season';
|
import Season from '../../entity/Season';
|
||||||
|
import { uniqWith } from 'lodash';
|
||||||
|
|
||||||
const BUNDLE_SIZE = 20;
|
const BUNDLE_SIZE = 20;
|
||||||
const UPDATE_RATE = 4 * 1000;
|
const UPDATE_RATE = 4 * 1000;
|
||||||
@@ -326,7 +327,25 @@ class JobPlexSync {
|
|||||||
`Beginning to process recently added for library: ${library.name}`,
|
`Beginning to process recently added for library: ${library.name}`,
|
||||||
'info'
|
'info'
|
||||||
);
|
);
|
||||||
this.items = await this.plexClient.getRecentlyAdded(library.id);
|
const libraryItems = await this.plexClient.getRecentlyAdded(
|
||||||
|
library.id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Bundle items up by rating keys
|
||||||
|
this.items = uniqWith(libraryItems, (mediaA, mediaB) => {
|
||||||
|
if (mediaA.grandparentRatingKey && mediaB.grandparentRatingKey) {
|
||||||
|
return (
|
||||||
|
mediaA.grandparentRatingKey === mediaB.grandparentRatingKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaA.parentRatingKey && mediaB.parentRatingKey) {
|
||||||
|
return mediaA.parentRatingKey === mediaB.parentRatingKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaA.ratingKey === mediaB.ratingKey;
|
||||||
|
});
|
||||||
|
|
||||||
await this.loop();
|
await this.loop();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -29,10 +29,13 @@ class EmailAgent implements NotificationAgent {
|
|||||||
host: emailSettings.smtpHost,
|
host: emailSettings.smtpHost,
|
||||||
port: emailSettings.smtpPort,
|
port: emailSettings.smtpPort,
|
||||||
secure: emailSettings.secure,
|
secure: emailSettings.secure,
|
||||||
auth: {
|
auth:
|
||||||
user: emailSettings.authUser,
|
emailSettings.authUser && emailSettings.authPass
|
||||||
pass: emailSettings.authPass,
|
? {
|
||||||
},
|
user: emailSettings.authUser,
|
||||||
|
pass: emailSettings.authPass,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export interface TvDetails {
|
|||||||
profilePath?: string;
|
profilePath?: string;
|
||||||
}[];
|
}[];
|
||||||
episodeRunTime: number[];
|
episodeRunTime: number[];
|
||||||
firstAirDate: string;
|
firstAirDate?: string;
|
||||||
genres: Genre[];
|
genres: Genre[];
|
||||||
homepage: string;
|
homepage: string;
|
||||||
inProduction: boolean;
|
inProduction: boolean;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
|
|||||||
return res.status(200).json(user.filter());
|
return res.status(200).json(user.filter());
|
||||||
});
|
});
|
||||||
|
|
||||||
authRoutes.post('/login', async (req, res) => {
|
authRoutes.post('/login', async (req, res, next) => {
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const body = req.body as { authToken?: string };
|
const body = req.body as { authToken?: string };
|
||||||
|
|
||||||
@@ -86,6 +86,22 @@ authRoutes.post('/login', async (req, res) => {
|
|||||||
avatar: account.thumb,
|
avatar: account.thumb,
|
||||||
});
|
});
|
||||||
await userRepository.save(user);
|
await userRepository.save(user);
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
'Failed login attempt from user without access to plex server',
|
||||||
|
{
|
||||||
|
label: 'Auth',
|
||||||
|
account: {
|
||||||
|
...account,
|
||||||
|
authentication_token: '__REDACTED__',
|
||||||
|
authToken: '__REDACTED__',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return next({
|
||||||
|
status: 403,
|
||||||
|
message: 'You do not have access to this Plex server',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,9 +113,10 @@ authRoutes.post('/login', async (req, res) => {
|
|||||||
return res.status(200).json(user?.filter() ?? {});
|
return res.status(200).json(user?.filter() ?? {});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(e.message, { label: 'Auth' });
|
logger.error(e.message, { label: 'Auth' });
|
||||||
res
|
return next({
|
||||||
.status(500)
|
status: 500,
|
||||||
.json({ error: 'Something went wrong. Is your auth token valid?' });
|
message: 'Something went wrong. Is your auth token valid?',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
112
server/subscriber/MediaSubscriber.ts
Normal file
112
server/subscriber/MediaSubscriber.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
EntitySubscriberInterface,
|
||||||
|
EventSubscriber,
|
||||||
|
getRepository,
|
||||||
|
UpdateEvent,
|
||||||
|
} from 'typeorm';
|
||||||
|
import TheMovieDb from '../api/themoviedb';
|
||||||
|
import { MediaStatus, MediaType } from '../constants/media';
|
||||||
|
import Media from '../entity/Media';
|
||||||
|
import { MediaRequest } from '../entity/MediaRequest';
|
||||||
|
import notificationManager, { Notification } from '../lib/notifications';
|
||||||
|
|
||||||
|
@EventSubscriber()
|
||||||
|
export class MediaSubscriber implements EntitySubscriberInterface {
|
||||||
|
private async notifyAvailableMovie(entity: Media) {
|
||||||
|
if (entity.status === MediaStatus.AVAILABLE) {
|
||||||
|
if (entity.mediaType === MediaType.MOVIE) {
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
const relatedRequests = await requestRepository.find({
|
||||||
|
where: { media: entity },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (relatedRequests.length > 0) {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
const movie = await tmdb.getMovie({ movieId: entity.tmdbId });
|
||||||
|
|
||||||
|
relatedRequests.forEach((request) => {
|
||||||
|
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||||
|
notifyUser: request.requestedBy,
|
||||||
|
subject: movie.title,
|
||||||
|
message: movie.overview,
|
||||||
|
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async notifyAvailableSeries(entity: Media, dbEntity: Media) {
|
||||||
|
const newAvailableSeasons = entity.seasons
|
||||||
|
.filter((season) => season.status === MediaStatus.AVAILABLE)
|
||||||
|
.map((season) => season.seasonNumber);
|
||||||
|
const oldAvailableSeasons = dbEntity.seasons
|
||||||
|
.filter((season) => season.status === MediaStatus.AVAILABLE)
|
||||||
|
.map((season) => season.seasonNumber);
|
||||||
|
|
||||||
|
const changedSeasons = newAvailableSeasons.filter(
|
||||||
|
(seasonNumber) => !oldAvailableSeasons.includes(seasonNumber)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (changedSeasons.length > 0) {
|
||||||
|
const tmdb = new TheMovieDb();
|
||||||
|
const requestRepository = getRepository(MediaRequest);
|
||||||
|
const processedSeasons: number[] = [];
|
||||||
|
|
||||||
|
for (const changedSeasonNumber of changedSeasons) {
|
||||||
|
const requests = await requestRepository.find({
|
||||||
|
where: { media: entity },
|
||||||
|
});
|
||||||
|
const request = requests.find(
|
||||||
|
(request) =>
|
||||||
|
// Check if the season is complete AND it contains the current season that was just marked available
|
||||||
|
request.seasons.every((season) =>
|
||||||
|
newAvailableSeasons.includes(season.seasonNumber)
|
||||||
|
) &&
|
||||||
|
request.seasons.some(
|
||||||
|
(season) => season.seasonNumber === changedSeasonNumber
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (request && !processedSeasons.includes(changedSeasonNumber)) {
|
||||||
|
processedSeasons.push(
|
||||||
|
...request.seasons.map((season) => season.seasonNumber)
|
||||||
|
);
|
||||||
|
const tv = await tmdb.getTvShow({ tvId: entity.tmdbId });
|
||||||
|
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
|
||||||
|
subject: tv.name,
|
||||||
|
message: tv.overview,
|
||||||
|
notifyUser: request.requestedBy,
|
||||||
|
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
|
||||||
|
extra: [
|
||||||
|
{
|
||||||
|
name: 'Seasons',
|
||||||
|
value: request.seasons
|
||||||
|
.map((season) => season.seasonNumber)
|
||||||
|
.join(', '),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public beforeUpdate(event: UpdateEvent<Media>): void {
|
||||||
|
if (
|
||||||
|
event.entity.mediaType === MediaType.MOVIE &&
|
||||||
|
event.entity.status === MediaStatus.AVAILABLE
|
||||||
|
) {
|
||||||
|
this.notifyAvailableMovie(event.entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
event.entity.mediaType === MediaType.TV &&
|
||||||
|
(event.entity.status === MediaStatus.AVAILABLE ||
|
||||||
|
event.entity.status === MediaStatus.PARTIALLY_AVAILABLE)
|
||||||
|
) {
|
||||||
|
this.notifyAvailableSeries(event.entity, event.databaseEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,15 @@ import axios from 'axios';
|
|||||||
import { useRouter } from 'next/dist/client/router';
|
import { useRouter } from 'next/dist/client/router';
|
||||||
import ImageFader from '../Common/ImageFader';
|
import ImageFader from '../Common/ImageFader';
|
||||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||||
|
import Transition from '../Transition';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
signinplex: 'Sign in to continue',
|
signinplex: 'Sign in to continue',
|
||||||
});
|
});
|
||||||
|
|
||||||
const Login: React.FC = () => {
|
const Login: React.FC = () => {
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [isProcessing, setProcessing] = useState(false);
|
||||||
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
|
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
|
||||||
const { user, revalidate } = useUser();
|
const { user, revalidate } = useUser();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -20,10 +23,17 @@ const Login: React.FC = () => {
|
|||||||
// ask swr to revalidate the user which _shouid_ come back with a valid user.
|
// ask swr to revalidate the user which _shouid_ come back with a valid user.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
const response = await axios.post('/api/v1/auth/login', { authToken });
|
setProcessing(true);
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/v1/auth/login', { authToken });
|
||||||
|
|
||||||
if (response.data?.email) {
|
if (response.data?.email) {
|
||||||
revalidate();
|
revalidate();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.response.data.message);
|
||||||
|
setAuthToken(undefined);
|
||||||
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (authToken) {
|
if (authToken) {
|
||||||
@@ -64,7 +74,40 @@ const Login: React.FC = () => {
|
|||||||
className="bg-gray-800 bg-opacity-50 py-8 px-4 shadow sm:rounded-lg sm:px-10"
|
className="bg-gray-800 bg-opacity-50 py-8 px-4 shadow sm:rounded-lg sm:px-10"
|
||||||
style={{ backdropFilter: 'blur(5px)' }}
|
style={{ backdropFilter: 'blur(5px)' }}
|
||||||
>
|
>
|
||||||
|
<Transition
|
||||||
|
show={!!error}
|
||||||
|
enter="opacity-0 transition duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="opacity-100 transition duration-300"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="rounded-md bg-red-600 p-4 mb-4">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-red-300"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-red-300">{error}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
<PlexLoginButton
|
<PlexLoginButton
|
||||||
|
isProcessing={isProcessing}
|
||||||
onAuthToken={(authToken) => setAuthToken(authToken)}
|
onAuthToken={(authToken) => setAuthToken(authToken)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,23 +12,23 @@ const plexOAuth = new PlexOAuth();
|
|||||||
|
|
||||||
interface PlexLoginButtonProps {
|
interface PlexLoginButtonProps {
|
||||||
onAuthToken: (authToken: string) => void;
|
onAuthToken: (authToken: string) => void;
|
||||||
|
isProcessing?: boolean;
|
||||||
onError?: (message: string) => void;
|
onError?: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlexLoginButton: React.FC<PlexLoginButtonProps> = ({
|
const PlexLoginButton: React.FC<PlexLoginButtonProps> = ({
|
||||||
onAuthToken,
|
onAuthToken,
|
||||||
onError,
|
onError,
|
||||||
|
isProcessing,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
|
||||||
|
|
||||||
const getPlexLogin = async () => {
|
const getPlexLogin = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const authToken = await plexOAuth.login();
|
const authToken = await plexOAuth.login();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setIsProcessing(true);
|
|
||||||
onAuthToken(authToken);
|
onAuthToken(authToken);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (onError) {
|
if (onError) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const messages = defineMessages({
|
|||||||
validationApiKeyRequired: 'You must provide an API key',
|
validationApiKeyRequired: 'You must provide an API key',
|
||||||
validationRootFolderRequired: 'You must select a root folder',
|
validationRootFolderRequired: 'You must select a root folder',
|
||||||
validationProfileRequired: 'You must select a profile',
|
validationProfileRequired: 'You must select a profile',
|
||||||
|
validationMinimumAvailabilityRequired: 'You must select minimum availability',
|
||||||
toastRadarrTestSuccess: 'Radarr connection established!',
|
toastRadarrTestSuccess: 'Radarr connection established!',
|
||||||
toastRadarrTestFailure: 'Failed to connect to Radarr Server',
|
toastRadarrTestFailure: 'Failed to connect to Radarr Server',
|
||||||
saving: 'Saving...',
|
saving: 'Saving...',
|
||||||
@@ -41,6 +42,10 @@ const messages = defineMessages({
|
|||||||
selectQualityProfile: 'Select a Quality Profile',
|
selectQualityProfile: 'Select a Quality Profile',
|
||||||
selectRootFolder: 'Select a Root Folder',
|
selectRootFolder: 'Select a Root Folder',
|
||||||
selectMinimumAvailability: 'Select minimum availability',
|
selectMinimumAvailability: 'Select minimum availability',
|
||||||
|
loadingprofiles: 'Loading quality profiles…',
|
||||||
|
testFirstQualityProfiles: 'Test your connection to load quality profiles',
|
||||||
|
loadingrootfolders: 'Loading root folders…',
|
||||||
|
testFirstRootFolders: 'Test your connection to load root folders',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface TestResponse {
|
interface TestResponse {
|
||||||
@@ -85,10 +90,15 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
|||||||
intl.formatMessage(messages.validationPortRequired)
|
intl.formatMessage(messages.validationPortRequired)
|
||||||
),
|
),
|
||||||
apiKey: Yup.string().required(intl.formatMessage(messages.apiKey)),
|
apiKey: Yup.string().required(intl.formatMessage(messages.apiKey)),
|
||||||
rootFolder: Yup.string().required(intl.formatMessage(messages.rootfolder)),
|
rootFolder: Yup.string().required(
|
||||||
|
intl.formatMessage(messages.validationRootFolderRequired)
|
||||||
|
),
|
||||||
activeProfileId: Yup.string().required(
|
activeProfileId: Yup.string().required(
|
||||||
intl.formatMessage(messages.validationProfileRequired)
|
intl.formatMessage(messages.validationProfileRequired)
|
||||||
),
|
),
|
||||||
|
minimumAvailability: Yup.string().required(
|
||||||
|
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const testConnection = useCallback(
|
const testConnection = useCallback(
|
||||||
@@ -175,7 +185,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
|||||||
baseUrl: radarr?.baseUrl,
|
baseUrl: radarr?.baseUrl,
|
||||||
activeProfileId: radarr?.activeProfileId,
|
activeProfileId: radarr?.activeProfileId,
|
||||||
rootFolder: radarr?.activeDirectory,
|
rootFolder: radarr?.activeDirectory,
|
||||||
minimumAvailability: radarr?.minimumAvailability,
|
minimumAvailability: radarr?.minimumAvailability ?? 'released',
|
||||||
isDefault: radarr?.isDefault ?? false,
|
isDefault: radarr?.isDefault ?? false,
|
||||||
is4k: radarr?.is4k ?? false,
|
is4k: radarr?.is4k ?? false,
|
||||||
}}
|
}}
|
||||||
@@ -222,6 +232,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
setFieldValue,
|
setFieldValue,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
|
isValid,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -254,7 +265,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
|||||||
secondaryDisabled={
|
secondaryDisabled={
|
||||||
!values.apiKey || !values.hostname || !values.port || isTesting
|
!values.apiKey || !values.hostname || !values.port || isTesting
|
||||||
}
|
}
|
||||||
okDisabled={!isValidated || isSubmitting || isTesting}
|
okDisabled={!isValidated || isSubmitting || isTesting || !isValid}
|
||||||
onOk={() => handleSubmit()}
|
onOk={() => handleSubmit()}
|
||||||
title={
|
title={
|
||||||
!radarr
|
!radarr
|
||||||
@@ -316,6 +327,9 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
|||||||
</label>
|
</label>
|
||||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||||
|
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-500 bg-gray-600 text-gray-100 sm:text-sm cursor-default">
|
||||||
|
{values.ssl ? 'https://' : 'http://'}
|
||||||
|
</span>
|
||||||
<Field
|
<Field
|
||||||
id="hostname"
|
id="hostname"
|
||||||
name="hostname"
|
name="hostname"
|
||||||
@@ -325,7 +339,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
|||||||
setIsValidated(false);
|
setIsValidated(false);
|
||||||
setFieldValue('hostname', e.target.value);
|
setFieldValue('hostname', e.target.value);
|
||||||
}}
|
}}
|
||||||
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
className="flex-1 form-input block w-full min-w-0 rounded-r-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.hostname && touched.hostname && (
|
{errors.hostname && touched.hostname && (
|
||||||
@@ -446,10 +460,17 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
|||||||
as="select"
|
as="select"
|
||||||
id="activeProfileId"
|
id="activeProfileId"
|
||||||
name="activeProfileId"
|
name="activeProfileId"
|
||||||
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5"
|
disabled={!isValidated || isTesting}
|
||||||
|
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
{intl.formatMessage(messages.selectQualityProfile)}
|
{isTesting
|
||||||
|
? intl.formatMessage(messages.loadingprofiles)
|
||||||
|
: !isValidated
|
||||||
|
? intl.formatMessage(
|
||||||
|
messages.testFirstQualityProfiles
|
||||||
|
)
|
||||||
|
: intl.formatMessage(messages.selectQualityProfile)}
|
||||||
</option>
|
</option>
|
||||||
{testResponse.profiles.length > 0 &&
|
{testResponse.profiles.length > 0 &&
|
||||||
testResponse.profiles.map((profile) => (
|
testResponse.profiles.map((profile) => (
|
||||||
@@ -482,10 +503,15 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
|||||||
as="select"
|
as="select"
|
||||||
id="rootFolder"
|
id="rootFolder"
|
||||||
name="rootFolder"
|
name="rootFolder"
|
||||||
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5"
|
disabled={!isValidated || isTesting}
|
||||||
|
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
{intl.formatMessage(messages.selectRootFolder)}
|
{isTesting
|
||||||
|
? intl.formatMessage(messages.loadingrootfolders)
|
||||||
|
: !isValidated
|
||||||
|
? intl.formatMessage(messages.testFirstRootFolders)
|
||||||
|
: intl.formatMessage(messages.selectRootFolder)}
|
||||||
</option>
|
</option>
|
||||||
{testResponse.rootFolders.length > 0 &&
|
{testResponse.rootFolders.length > 0 &&
|
||||||
testResponse.rootFolders.map((folder) => (
|
testResponse.rootFolders.map((folder) => (
|
||||||
@@ -520,17 +546,18 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
|||||||
name="minimumAvailability"
|
name="minimumAvailability"
|
||||||
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5"
|
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5"
|
||||||
>
|
>
|
||||||
<option value="">
|
|
||||||
{intl.formatMessage(
|
|
||||||
messages.selectMinimumAvailability
|
|
||||||
)}
|
|
||||||
</option>
|
|
||||||
<option value="announced">Announced</option>
|
<option value="announced">Announced</option>
|
||||||
<option value="inCinemas">In Cinemas</option>
|
<option value="inCinemas">In Cinemas</option>
|
||||||
<option value="released">Released</option>
|
<option value="released">Released</option>
|
||||||
<option value="preDB">PreDB</option>
|
<option value="preDB">PreDB</option>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
{errors.minimumAvailability &&
|
||||||
|
touched.minimumAvailability && (
|
||||||
|
<div className="text-red-500 mt-2">
|
||||||
|
{errors.minimumAvailability}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
|
<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-200 sm:pt-5">
|
||||||
|
|||||||
@@ -224,12 +224,15 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
|
|||||||
</label>
|
</label>
|
||||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||||
|
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-500 bg-gray-800 text-gray-100 sm:text-sm cursor-default">
|
||||||
|
{values.useSsl ? 'https://' : 'http://'}
|
||||||
|
</span>
|
||||||
<Field
|
<Field
|
||||||
type="text"
|
type="text"
|
||||||
id="hostname"
|
id="hostname"
|
||||||
name="hostname"
|
name="hostname"
|
||||||
placeholder="127.0.0.1"
|
placeholder="127.0.0.1"
|
||||||
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
className="flex-1 form-input block w-full min-w-0 rounded-r-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.hostname && touched.hostname && (
|
{errors.hostname && touched.hostname && (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
import Badge from '../Common/Badge';
|
import Badge from '../Common/Badge';
|
||||||
import Button from '../Common/Button';
|
import Button from '../Common/Button';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
@@ -31,8 +31,48 @@ const messages = defineMessages({
|
|||||||
activeProfile: 'Active Profile',
|
activeProfile: 'Active Profile',
|
||||||
addradarr: 'Add Radarr Server',
|
addradarr: 'Add Radarr Server',
|
||||||
addsonarr: 'Add Sonarr Server',
|
addsonarr: 'Add Sonarr Server',
|
||||||
|
nodefault: 'No default server selected!',
|
||||||
|
nodefaultdescription:
|
||||||
|
'At least one server must be marked as default before any requests will make it to your services.',
|
||||||
|
no4kimplemented: '(Default 4K servers are not currently implemented)',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const NoDefaultAlert: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
return (
|
||||||
|
<div className="rounded-md bg-yellow-600 p-4 mb-8">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-yellow-200"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-yellow-200">
|
||||||
|
{intl.formatMessage(messages.nodefault)}
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 text-sm text-yellow-300">
|
||||||
|
<p>{intl.formatMessage(messages.nodefaultdescription)}</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
{intl.formatMessage(messages.no4kimplemented)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
interface ServerInstanceProps {
|
interface ServerInstanceProps {
|
||||||
name: string;
|
name: string;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
@@ -249,51 +289,57 @@ const SettingsServices: React.FC = () => {
|
|||||||
<div className="mt-6 sm:mt-5">
|
<div className="mt-6 sm:mt-5">
|
||||||
{!radarrData && !radarrError && <LoadingSpinner />}
|
{!radarrData && !radarrError && <LoadingSpinner />}
|
||||||
{radarrData && !radarrError && (
|
{radarrData && !radarrError && (
|
||||||
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<>
|
||||||
{radarrData.map((radarr) => (
|
{radarrData.length > 0 &&
|
||||||
<ServerInstance
|
!radarrData.some(
|
||||||
key={`radarr-config-${radarr.id}`}
|
(radarr) => radarr.isDefault && !radarr.is4k
|
||||||
name={radarr.name}
|
) && <NoDefaultAlert />}
|
||||||
address={radarr.hostname}
|
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
profileName={radarr.activeProfileName}
|
{radarrData.map((radarr) => (
|
||||||
isSSL={radarr.useSsl}
|
<ServerInstance
|
||||||
isDefault={radarr.isDefault && !radarr.is4k}
|
key={`radarr-config-${radarr.id}`}
|
||||||
isDefault4K={radarr.is4k && radarr.isDefault}
|
name={radarr.name}
|
||||||
onEdit={() => setEditRadarrModal({ open: true, radarr })}
|
address={radarr.hostname}
|
||||||
onDelete={() =>
|
profileName={radarr.activeProfileName}
|
||||||
setDeleteServerModal({
|
isSSL={radarr.useSsl}
|
||||||
open: true,
|
isDefault={radarr.isDefault && !radarr.is4k}
|
||||||
serverId: radarr.id,
|
isDefault4K={radarr.is4k && radarr.isDefault}
|
||||||
type: 'radarr',
|
onEdit={() => setEditRadarrModal({ open: true, radarr })}
|
||||||
})
|
onDelete={() =>
|
||||||
}
|
setDeleteServerModal({
|
||||||
/>
|
open: true,
|
||||||
))}
|
serverId: radarr.id,
|
||||||
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
|
type: 'radarr',
|
||||||
<div className="flex items-center justify-center w-full h-full">
|
})
|
||||||
<Button
|
|
||||||
buttonType="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
setEditRadarrModal({ open: true, radarr: null })
|
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
<svg
|
))}
|
||||||
className="w-5 h-5 mr-1"
|
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
|
||||||
fill="currentColor"
|
<div className="flex items-center justify-center w-full h-full">
|
||||||
viewBox="0 0 20 20"
|
<Button
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
buttonType="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
setEditRadarrModal({ open: true, radarr: null })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
fillRule="evenodd"
|
className="w-5 h-5 mr-1"
|
||||||
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
fill="currentColor"
|
||||||
clipRule="evenodd"
|
viewBox="0 0 20 20"
|
||||||
/>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</svg>
|
>
|
||||||
<FormattedMessage {...messages.addradarr} />
|
<path
|
||||||
</Button>
|
fillRule="evenodd"
|
||||||
</div>
|
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
||||||
</li>
|
clipRule="evenodd"
|
||||||
</ul>
|
/>
|
||||||
|
</svg>
|
||||||
|
<FormattedMessage {...messages.addradarr} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
@@ -307,52 +353,58 @@ const SettingsServices: React.FC = () => {
|
|||||||
<div className="mt-6 sm:mt-5">
|
<div className="mt-6 sm:mt-5">
|
||||||
{!sonarrData && !sonarrError && <LoadingSpinner />}
|
{!sonarrData && !sonarrError && <LoadingSpinner />}
|
||||||
{sonarrData && !sonarrError && (
|
{sonarrData && !sonarrError && (
|
||||||
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<>
|
||||||
{sonarrData.map((sonarr) => (
|
{sonarrData.length > 0 &&
|
||||||
<ServerInstance
|
!sonarrData.some(
|
||||||
key={`sonarr-config-${sonarr.id}`}
|
(sonarr) => sonarr.isDefault && !sonarr.is4k
|
||||||
name={sonarr.name}
|
) && <NoDefaultAlert />}
|
||||||
address={sonarr.hostname}
|
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
profileName={sonarr.activeProfileName}
|
{sonarrData.map((sonarr) => (
|
||||||
isSSL={sonarr.useSsl}
|
<ServerInstance
|
||||||
isSonarr
|
key={`sonarr-config-${sonarr.id}`}
|
||||||
isDefault4K={sonarr.isDefault && sonarr.is4k}
|
name={sonarr.name}
|
||||||
isDefault={sonarr.isDefault && !sonarr.is4k}
|
address={sonarr.hostname}
|
||||||
onEdit={() => setEditSonarrModal({ open: true, sonarr })}
|
profileName={sonarr.activeProfileName}
|
||||||
onDelete={() =>
|
isSSL={sonarr.useSsl}
|
||||||
setDeleteServerModal({
|
isSonarr
|
||||||
open: true,
|
isDefault4K={sonarr.isDefault && sonarr.is4k}
|
||||||
serverId: sonarr.id,
|
isDefault={sonarr.isDefault && !sonarr.is4k}
|
||||||
type: 'sonarr',
|
onEdit={() => setEditSonarrModal({ open: true, sonarr })}
|
||||||
})
|
onDelete={() =>
|
||||||
}
|
setDeleteServerModal({
|
||||||
/>
|
open: true,
|
||||||
))}
|
serverId: sonarr.id,
|
||||||
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
|
type: 'sonarr',
|
||||||
<div className="flex items-center justify-center w-full h-full">
|
})
|
||||||
<Button
|
|
||||||
buttonType="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
setEditSonarrModal({ open: true, sonarr: null })
|
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
<svg
|
))}
|
||||||
className="w-5 h-5 mr-1"
|
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
|
||||||
fill="currentColor"
|
<div className="flex items-center justify-center w-full h-full">
|
||||||
viewBox="0 0 20 20"
|
<Button
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
buttonType="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
setEditSonarrModal({ open: true, sonarr: null })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
fillRule="evenodd"
|
className="w-5 h-5 mr-1"
|
||||||
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
fill="currentColor"
|
||||||
clipRule="evenodd"
|
viewBox="0 0 20 20"
|
||||||
/>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</svg>
|
>
|
||||||
<FormattedMessage {...messages.addsonarr} />
|
<path
|
||||||
</Button>
|
fillRule="evenodd"
|
||||||
</div>
|
d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z"
|
||||||
</li>
|
clipRule="evenodd"
|
||||||
</ul>
|
/>
|
||||||
|
</svg>
|
||||||
|
<FormattedMessage {...messages.addsonarr} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ const messages = defineMessages({
|
|||||||
server4k: '4K Server',
|
server4k: '4K Server',
|
||||||
selectQualityProfile: 'Select a Quality Profile',
|
selectQualityProfile: 'Select a Quality Profile',
|
||||||
selectRootFolder: 'Select a Root Folder',
|
selectRootFolder: 'Select a Root Folder',
|
||||||
|
loadingprofiles: 'Loading quality profiles…',
|
||||||
|
testFirstQualityProfiles: 'Test your connection to load quality profiles',
|
||||||
|
loadingrootfolders: 'Loading root folders…',
|
||||||
|
testFirstRootFolders: 'Test your connection to load root folders',
|
||||||
});
|
});
|
||||||
|
|
||||||
interface TestResponse {
|
interface TestResponse {
|
||||||
@@ -225,6 +229,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
setFieldValue,
|
setFieldValue,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
|
isValid,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -257,7 +262,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
|||||||
secondaryDisabled={
|
secondaryDisabled={
|
||||||
!values.apiKey || !values.hostname || !values.port || isTesting
|
!values.apiKey || !values.hostname || !values.port || isTesting
|
||||||
}
|
}
|
||||||
okDisabled={!isValidated || isSubmitting || isTesting}
|
okDisabled={!isValidated || isSubmitting || isTesting || !isValid}
|
||||||
onOk={() => handleSubmit()}
|
onOk={() => handleSubmit()}
|
||||||
title={
|
title={
|
||||||
!sonarr
|
!sonarr
|
||||||
@@ -319,6 +324,9 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
|||||||
</label>
|
</label>
|
||||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||||
<div className="max-w-lg flex rounded-md shadow-sm">
|
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||||
|
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-gray-500 bg-gray-600 text-gray-100 sm:text-sm cursor-default">
|
||||||
|
{values.ssl ? 'https://' : 'http://'}
|
||||||
|
</span>
|
||||||
<Field
|
<Field
|
||||||
id="hostname"
|
id="hostname"
|
||||||
name="hostname"
|
name="hostname"
|
||||||
@@ -328,7 +336,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
|||||||
setIsValidated(false);
|
setIsValidated(false);
|
||||||
setFieldValue('hostname', e.target.value);
|
setFieldValue('hostname', e.target.value);
|
||||||
}}
|
}}
|
||||||
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
className="flex-1 form-input block w-full min-w-0 rounded-r-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{errors.hostname && touched.hostname && (
|
{errors.hostname && touched.hostname && (
|
||||||
@@ -449,10 +457,17 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
|||||||
as="select"
|
as="select"
|
||||||
id="activeProfileId"
|
id="activeProfileId"
|
||||||
name="activeProfileId"
|
name="activeProfileId"
|
||||||
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5"
|
disabled={!isValidated || isTesting}
|
||||||
|
className="mt-1 form-select rounded-md block w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
{intl.formatMessage(messages.selectQualityProfile)}
|
{isTesting
|
||||||
|
? intl.formatMessage(messages.loadingprofiles)
|
||||||
|
: !isValidated
|
||||||
|
? intl.formatMessage(
|
||||||
|
messages.testFirstQualityProfiles
|
||||||
|
)
|
||||||
|
: intl.formatMessage(messages.selectQualityProfile)}
|
||||||
</option>
|
</option>
|
||||||
{testResponse.profiles.length > 0 &&
|
{testResponse.profiles.length > 0 &&
|
||||||
testResponse.profiles.map((profile) => (
|
testResponse.profiles.map((profile) => (
|
||||||
@@ -485,10 +500,15 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
|||||||
as="select"
|
as="select"
|
||||||
id="rootFolder"
|
id="rootFolder"
|
||||||
name="rootFolder"
|
name="rootFolder"
|
||||||
className="mt-1 form-select block rounded-md w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5"
|
disabled={!isValidated || isTesting}
|
||||||
|
className="mt-1 form-select block rounded-md w-full pl-3 pr-10 py-2 text-base leading-6 bg-gray-700 border-gray-500 focus:outline-none focus:ring-blue focus:border-gray-500 sm:text-sm sm:leading-5 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
{intl.formatMessage(messages.selectRootFolder)}
|
{isTesting
|
||||||
|
? intl.formatMessage(messages.loadingrootfolders)
|
||||||
|
: !isValidated
|
||||||
|
? intl.formatMessage(messages.testFirstRootFolders)
|
||||||
|
: intl.formatMessage(messages.selectRootFolder)}
|
||||||
</option>
|
</option>
|
||||||
{testResponse.rootFolders.length > 0 &&
|
{testResponse.rootFolders.length > 0 &&
|
||||||
testResponse.rootFolders.map((folder) => (
|
testResponse.rootFolders.map((folder) => (
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ interface TitleCardProps {
|
|||||||
id: number;
|
id: number;
|
||||||
image?: string;
|
image?: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
year: string;
|
year?: string;
|
||||||
title: string;
|
title: string;
|
||||||
userScore: number;
|
userScore: number;
|
||||||
mediaType: MediaType;
|
mediaType: MediaType;
|
||||||
@@ -169,7 +169,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
|||||||
>
|
>
|
||||||
<div className="absolute bottom-0 w-full left-0 right-0">
|
<div className="absolute bottom-0 w-full left-0 right-0">
|
||||||
<div className="px-2 text-white">
|
<div className="px-2 text-white">
|
||||||
<div className="text-sm">{year}</div>
|
{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}
|
||||||
|
|||||||
@@ -227,8 +227,12 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl md:text-4xl">
|
<h1 className="text-2xl md:text-4xl">
|
||||||
{data.name}{' '}
|
<span>{data.name}</span>
|
||||||
<span className="text-2xl">({data.firstAirDate.slice(0, 4)})</span>
|
{data.firstAirDate && (
|
||||||
|
<span className="text-2xl ml-2">
|
||||||
|
({data.firstAirDate.slice(0, 4)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
<span className="text-xs md:text-base mt-1 md:mt-0">
|
<span className="text-xs md:text-base mt-1 md:mt-0">
|
||||||
{data.genres.map((g) => g.name).join(', ')}
|
{data.genres.map((g) => g.name).join(', ')}
|
||||||
|
|||||||
@@ -116,6 +116,8 @@
|
|||||||
"components.Settings.RadarrModal.defaultserver": "Default Server",
|
"components.Settings.RadarrModal.defaultserver": "Default Server",
|
||||||
"components.Settings.RadarrModal.editradarr": "Edit Radarr Server",
|
"components.Settings.RadarrModal.editradarr": "Edit Radarr Server",
|
||||||
"components.Settings.RadarrModal.hostname": "Hostname",
|
"components.Settings.RadarrModal.hostname": "Hostname",
|
||||||
|
"components.Settings.RadarrModal.loadingprofiles": "Loading quality profiles…",
|
||||||
|
"components.Settings.RadarrModal.loadingrootfolders": "Loading root folders…",
|
||||||
"components.Settings.RadarrModal.minimumAvailability": "Minimum Availability",
|
"components.Settings.RadarrModal.minimumAvailability": "Minimum Availability",
|
||||||
"components.Settings.RadarrModal.port": "Port",
|
"components.Settings.RadarrModal.port": "Port",
|
||||||
"components.Settings.RadarrModal.qualityprofile": "Quality Profile",
|
"components.Settings.RadarrModal.qualityprofile": "Quality Profile",
|
||||||
@@ -130,11 +132,14 @@
|
|||||||
"components.Settings.RadarrModal.servernamePlaceholder": "A Radarr Server",
|
"components.Settings.RadarrModal.servernamePlaceholder": "A Radarr Server",
|
||||||
"components.Settings.RadarrModal.ssl": "SSL",
|
"components.Settings.RadarrModal.ssl": "SSL",
|
||||||
"components.Settings.RadarrModal.test": "Test",
|
"components.Settings.RadarrModal.test": "Test",
|
||||||
|
"components.Settings.RadarrModal.testFirstQualityProfiles": "Test your connection to load quality profiles",
|
||||||
|
"components.Settings.RadarrModal.testFirstRootFolders": "Test your connection to load root folders",
|
||||||
"components.Settings.RadarrModal.testing": "Testing...",
|
"components.Settings.RadarrModal.testing": "Testing...",
|
||||||
"components.Settings.RadarrModal.toastRadarrTestFailure": "Failed to connect to Radarr Server",
|
"components.Settings.RadarrModal.toastRadarrTestFailure": "Failed to connect to Radarr Server",
|
||||||
"components.Settings.RadarrModal.toastRadarrTestSuccess": "Radarr connection established!",
|
"components.Settings.RadarrModal.toastRadarrTestSuccess": "Radarr connection established!",
|
||||||
"components.Settings.RadarrModal.validationApiKeyRequired": "You must provide an API key",
|
"components.Settings.RadarrModal.validationApiKeyRequired": "You must provide an API key",
|
||||||
"components.Settings.RadarrModal.validationHostnameRequired": "You must provide a hostname/IP",
|
"components.Settings.RadarrModal.validationHostnameRequired": "You must provide a hostname/IP",
|
||||||
|
"components.Settings.RadarrModal.validationMinimumAvailabilityRequired": "You must select minimum availability",
|
||||||
"components.Settings.RadarrModal.validationNameRequired": "You must provide a server name",
|
"components.Settings.RadarrModal.validationNameRequired": "You must provide a server name",
|
||||||
"components.Settings.RadarrModal.validationPortRequired": "You must provide a port",
|
"components.Settings.RadarrModal.validationPortRequired": "You must provide a port",
|
||||||
"components.Settings.RadarrModal.validationProfileRequired": "You must select a profile",
|
"components.Settings.RadarrModal.validationProfileRequired": "You must select a profile",
|
||||||
@@ -155,6 +160,8 @@
|
|||||||
"components.Settings.SonarrModal.defaultserver": "Default Server",
|
"components.Settings.SonarrModal.defaultserver": "Default Server",
|
||||||
"components.Settings.SonarrModal.editsonarr": "Edit Sonarr Server",
|
"components.Settings.SonarrModal.editsonarr": "Edit Sonarr Server",
|
||||||
"components.Settings.SonarrModal.hostname": "Hostname",
|
"components.Settings.SonarrModal.hostname": "Hostname",
|
||||||
|
"components.Settings.SonarrModal.loadingprofiles": "Loading quality profiles…",
|
||||||
|
"components.Settings.SonarrModal.loadingrootfolders": "Loading root folders…",
|
||||||
"components.Settings.SonarrModal.port": "Port",
|
"components.Settings.SonarrModal.port": "Port",
|
||||||
"components.Settings.SonarrModal.qualityprofile": "Quality Profile",
|
"components.Settings.SonarrModal.qualityprofile": "Quality Profile",
|
||||||
"components.Settings.SonarrModal.rootfolder": "Root Folder",
|
"components.Settings.SonarrModal.rootfolder": "Root Folder",
|
||||||
@@ -168,6 +175,8 @@
|
|||||||
"components.Settings.SonarrModal.servernamePlaceholder": "A Sonarr Server",
|
"components.Settings.SonarrModal.servernamePlaceholder": "A 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.testFirstQualityProfiles": "Test your connection to load quality profiles",
|
||||||
|
"components.Settings.SonarrModal.testFirstRootFolders": "Test your connection to load root folders",
|
||||||
"components.Settings.SonarrModal.testing": "Testing...",
|
"components.Settings.SonarrModal.testing": "Testing...",
|
||||||
"components.Settings.SonarrModal.toastRadarrTestFailure": "Could not connect to Sonarr Server",
|
"components.Settings.SonarrModal.toastRadarrTestFailure": "Could not connect to Sonarr Server",
|
||||||
"components.Settings.SonarrModal.toastRadarrTestSuccess": "Sonarr connection established!",
|
"components.Settings.SonarrModal.toastRadarrTestSuccess": "Sonarr connection established!",
|
||||||
@@ -206,6 +215,9 @@
|
|||||||
"components.Settings.menuPlexSettings": "Plex",
|
"components.Settings.menuPlexSettings": "Plex",
|
||||||
"components.Settings.menuServices": "Services",
|
"components.Settings.menuServices": "Services",
|
||||||
"components.Settings.nextexecution": "Next Execution",
|
"components.Settings.nextexecution": "Next Execution",
|
||||||
|
"components.Settings.no4kimplemented": "(Default 4K servers are not currently implemented)",
|
||||||
|
"components.Settings.nodefault": "No default server selected!",
|
||||||
|
"components.Settings.nodefaultdescription": "At least one server must be marked as default before any requests will make it to your services.",
|
||||||
"components.Settings.notificationsettings": "Notification Settings",
|
"components.Settings.notificationsettings": "Notification Settings",
|
||||||
"components.Settings.notificationsettingsDescription": "Here you can pick and choose what types of notifications to send and through what types of services.",
|
"components.Settings.notificationsettingsDescription": "Here you can pick and choose what types of notifications to send and through what types of services.",
|
||||||
"components.Settings.notrunning": "Not Running",
|
"components.Settings.notrunning": "Not Running",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.plex-button {
|
.plex-button {
|
||||||
@apply w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 transition ease-in-out duration-150 text-center;
|
@apply w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 transition ease-in-out duration-150 text-center disabled:opacity-50;
|
||||||
background-color: #cc7b19;
|
background-color: #cc7b19;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user