mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
Merge branch 'develop'
This commit is contained in:
@@ -124,6 +124,26 @@
|
||||
"contributions": [
|
||||
"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>",
|
||||
|
||||
@@ -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.
|
||||
- Please make meaningful commits, or squash them
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<a href="https://lgtm.com/projects/g/sct/overseerr/context:javascript"><img alt="Language grade: JavaScript" src="https://img.shields.io/lgtm/grade/javascript/g/sct/overseerr.svg?logo=lgtm&logoWidth=18"/></a>
|
||||
<img alt="GitHub" src="https://img.shields.io/github/license/sct/overseerr">
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
<a href="#contributors-"><img alt="All Contributors" src="https://img.shields.io/badge/all_contributors-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 -->
|
||||
</p>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
- More notification types (Slack/Telegram/etc.).
|
||||
- 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).
|
||||
- Compatiblity APIs (to work with existing tools in your system).
|
||||
- Compatibility APIs (to work with existing tools in your system).
|
||||
|
||||
## 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/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/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>
|
||||
</table>
|
||||
|
||||
<!-- markdownlint-enable -->
|
||||
<!-- prettier-ignore-end -->
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
@@ -5,6 +5,7 @@ const devConfig = {
|
||||
logging: false,
|
||||
entities: ['server/entity/**/*.ts'],
|
||||
migrations: ['server/migration/**/*.ts'],
|
||||
subscribers: ['server/subscriber/**/*.ts'],
|
||||
cli: {
|
||||
entitiesDir: 'server/entity',
|
||||
migrationsDir: 'server/migration',
|
||||
@@ -19,6 +20,7 @@ const prodConfig = {
|
||||
entities: ['dist/entity/**/*.js'],
|
||||
migrations: ['dist/migration/**/*.js'],
|
||||
migrationsRun: true,
|
||||
subscribers: ['dist/subscriber/**/*.js'],
|
||||
cli: {
|
||||
entitiesDir: 'dist/entity',
|
||||
migrationsDir: 'dist/migration',
|
||||
|
||||
@@ -76,6 +76,7 @@ interface AddSeriesOptions {
|
||||
title: string;
|
||||
profileId: number;
|
||||
seasons: number[];
|
||||
seasonFolder: boolean;
|
||||
rootFolderPath: string;
|
||||
monitored?: boolean;
|
||||
searchNow?: boolean;
|
||||
@@ -149,6 +150,7 @@ class SonarrAPI {
|
||||
monitored: false,
|
||||
}))
|
||||
),
|
||||
seasonFolder: options.seasonFolder,
|
||||
monitored: options.monitored,
|
||||
rootFolderPath: options.rootFolderPath,
|
||||
addOptions: {
|
||||
|
||||
@@ -8,14 +8,11 @@ import {
|
||||
UpdateDateColumn,
|
||||
getRepository,
|
||||
In,
|
||||
AfterUpdate,
|
||||
} from 'typeorm';
|
||||
import { MediaRequest } from './MediaRequest';
|
||||
import { MediaStatus, MediaType } from '../constants/media';
|
||||
import logger from '../logger';
|
||||
import Season from './Season';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
|
||||
@Entity()
|
||||
class Media {
|
||||
@@ -98,32 +95,6 @@ class Media {
|
||||
constructor(init?: Partial<Media>) {
|
||||
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;
|
||||
|
||||
@@ -335,6 +335,7 @@ export class MediaRequest {
|
||||
title: series.name,
|
||||
tvdbid: series.external_ids.tvdb_id,
|
||||
seasons: this.seasons.map((season) => season.seasonNumber),
|
||||
seasonFolder: sonarrSettings.enableSeasonFolders,
|
||||
monitored: true,
|
||||
searchNow: true,
|
||||
});
|
||||
|
||||
@@ -5,15 +5,9 @@ import {
|
||||
ManyToOne,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
AfterInsert,
|
||||
AfterUpdate,
|
||||
getRepository,
|
||||
} from 'typeorm';
|
||||
import { MediaStatus } from '../constants/media';
|
||||
import Media from './Media';
|
||||
import logger from '../logger';
|
||||
import TheMovieDb from '../api/themoviedb';
|
||||
import notificationManager, { Notification } from '../lib/notifications';
|
||||
|
||||
@Entity()
|
||||
class Season {
|
||||
@@ -38,60 +32,6 @@ class Season {
|
||||
constructor(init?: Partial<Season>) {
|
||||
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;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MediaStatus, MediaType } from '../../constants/media';
|
||||
import logger from '../../logger';
|
||||
import { getSettings, Library } from '../../lib/settings';
|
||||
import Season from '../../entity/Season';
|
||||
import { uniqWith } from 'lodash';
|
||||
|
||||
const BUNDLE_SIZE = 20;
|
||||
const UPDATE_RATE = 4 * 1000;
|
||||
@@ -326,7 +327,25 @@ class JobPlexSync {
|
||||
`Beginning to process recently added for library: ${library.name}`,
|
||||
'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();
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -29,10 +29,13 @@ class EmailAgent implements NotificationAgent {
|
||||
host: emailSettings.smtpHost,
|
||||
port: emailSettings.smtpPort,
|
||||
secure: emailSettings.secure,
|
||||
auth: {
|
||||
user: emailSettings.authUser,
|
||||
pass: emailSettings.authPass,
|
||||
},
|
||||
auth:
|
||||
emailSettings.authUser && emailSettings.authPass
|
||||
? {
|
||||
user: emailSettings.authUser,
|
||||
pass: emailSettings.authPass,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export interface TvDetails {
|
||||
profilePath?: string;
|
||||
}[];
|
||||
episodeRunTime: number[];
|
||||
firstAirDate: string;
|
||||
firstAirDate?: string;
|
||||
genres: Genre[];
|
||||
homepage: string;
|
||||
inProduction: boolean;
|
||||
|
||||
@@ -24,7 +24,7 @@ authRoutes.get('/me', isAuthenticated(), async (req, res) => {
|
||||
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 body = req.body as { authToken?: string };
|
||||
|
||||
@@ -86,6 +86,22 @@ authRoutes.post('/login', async (req, res) => {
|
||||
avatar: account.thumb,
|
||||
});
|
||||
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() ?? {});
|
||||
} catch (e) {
|
||||
logger.error(e.message, { label: 'Auth' });
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: 'Something went wrong. Is your auth token valid?' });
|
||||
return next({
|
||||
status: 500,
|
||||
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 ImageFader from '../Common/ImageFader';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
import Transition from '../Transition';
|
||||
|
||||
const messages = defineMessages({
|
||||
signinplex: 'Sign in to continue',
|
||||
});
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [error, setError] = useState('');
|
||||
const [isProcessing, setProcessing] = useState(false);
|
||||
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
|
||||
const { user, revalidate } = useUser();
|
||||
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.
|
||||
useEffect(() => {
|
||||
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) {
|
||||
revalidate();
|
||||
if (response.data?.email) {
|
||||
revalidate();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.response.data.message);
|
||||
setAuthToken(undefined);
|
||||
setProcessing(false);
|
||||
}
|
||||
};
|
||||
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"
|
||||
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
|
||||
isProcessing={isProcessing}
|
||||
onAuthToken={(authToken) => setAuthToken(authToken)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -12,23 +12,23 @@ const plexOAuth = new PlexOAuth();
|
||||
|
||||
interface PlexLoginButtonProps {
|
||||
onAuthToken: (authToken: string) => void;
|
||||
isProcessing?: boolean;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
const PlexLoginButton: React.FC<PlexLoginButtonProps> = ({
|
||||
onAuthToken,
|
||||
onError,
|
||||
isProcessing,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const getPlexLogin = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const authToken = await plexOAuth.login();
|
||||
setLoading(false);
|
||||
setIsProcessing(true);
|
||||
onAuthToken(authToken);
|
||||
} catch (e) {
|
||||
if (onError) {
|
||||
|
||||
@@ -17,6 +17,7 @@ const messages = defineMessages({
|
||||
validationApiKeyRequired: 'You must provide an API key',
|
||||
validationRootFolderRequired: 'You must select a root folder',
|
||||
validationProfileRequired: 'You must select a profile',
|
||||
validationMinimumAvailabilityRequired: 'You must select minimum availability',
|
||||
toastRadarrTestSuccess: 'Radarr connection established!',
|
||||
toastRadarrTestFailure: 'Failed to connect to Radarr Server',
|
||||
saving: 'Saving...',
|
||||
@@ -41,6 +42,10 @@ const messages = defineMessages({
|
||||
selectQualityProfile: 'Select a Quality Profile',
|
||||
selectRootFolder: 'Select a Root Folder',
|
||||
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 {
|
||||
@@ -85,10 +90,15 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
intl.formatMessage(messages.validationPortRequired)
|
||||
),
|
||||
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(
|
||||
intl.formatMessage(messages.validationProfileRequired)
|
||||
),
|
||||
minimumAvailability: Yup.string().required(
|
||||
intl.formatMessage(messages.validationMinimumAvailabilityRequired)
|
||||
),
|
||||
});
|
||||
|
||||
const testConnection = useCallback(
|
||||
@@ -175,7 +185,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
baseUrl: radarr?.baseUrl,
|
||||
activeProfileId: radarr?.activeProfileId,
|
||||
rootFolder: radarr?.activeDirectory,
|
||||
minimumAvailability: radarr?.minimumAvailability,
|
||||
minimumAvailability: radarr?.minimumAvailability ?? 'released',
|
||||
isDefault: radarr?.isDefault ?? false,
|
||||
is4k: radarr?.is4k ?? false,
|
||||
}}
|
||||
@@ -222,6 +232,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
handleSubmit,
|
||||
setFieldValue,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
@@ -254,7 +265,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
secondaryDisabled={
|
||||
!values.apiKey || !values.hostname || !values.port || isTesting
|
||||
}
|
||||
okDisabled={!isValidated || isSubmitting || isTesting}
|
||||
okDisabled={!isValidated || isSubmitting || isTesting || !isValid}
|
||||
onOk={() => handleSubmit()}
|
||||
title={
|
||||
!radarr
|
||||
@@ -316,6 +327,9 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<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
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
@@ -325,7 +339,7 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
setIsValidated(false);
|
||||
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>
|
||||
{errors.hostname && touched.hostname && (
|
||||
@@ -446,10 +460,17 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
as="select"
|
||||
id="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="">
|
||||
{intl.formatMessage(messages.selectQualityProfile)}
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingprofiles)
|
||||
: !isValidated
|
||||
? intl.formatMessage(
|
||||
messages.testFirstQualityProfiles
|
||||
)
|
||||
: intl.formatMessage(messages.selectQualityProfile)}
|
||||
</option>
|
||||
{testResponse.profiles.length > 0 &&
|
||||
testResponse.profiles.map((profile) => (
|
||||
@@ -482,10 +503,15 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
as="select"
|
||||
id="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="">
|
||||
{intl.formatMessage(messages.selectRootFolder)}
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingrootfolders)
|
||||
: !isValidated
|
||||
? intl.formatMessage(messages.testFirstRootFolders)
|
||||
: intl.formatMessage(messages.selectRootFolder)}
|
||||
</option>
|
||||
{testResponse.rootFolders.length > 0 &&
|
||||
testResponse.rootFolders.map((folder) => (
|
||||
@@ -520,17 +546,18 @@ const RadarrModal: React.FC<RadarrModalProps> = ({
|
||||
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"
|
||||
>
|
||||
<option value="">
|
||||
{intl.formatMessage(
|
||||
messages.selectMinimumAvailability
|
||||
)}
|
||||
</option>
|
||||
<option value="announced">Announced</option>
|
||||
<option value="inCinemas">In Cinemas</option>
|
||||
<option value="released">Released</option>
|
||||
<option value="preDB">PreDB</option>
|
||||
</Field>
|
||||
</div>
|
||||
{errors.minimumAvailability &&
|
||||
touched.minimumAvailability && (
|
||||
<div className="text-red-500 mt-2">
|
||||
{errors.minimumAvailability}
|
||||
</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">
|
||||
|
||||
@@ -224,12 +224,15 @@ const SettingsPlex: React.FC<SettingsPlexProps> = ({ onComplete }) => {
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<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
|
||||
type="text"
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
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>
|
||||
{errors.hostname && touched.hostname && (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { defineMessages, FormattedMessage } from 'react-intl';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
import Badge from '../Common/Badge';
|
||||
import Button from '../Common/Button';
|
||||
import useSWR from 'swr';
|
||||
@@ -31,8 +31,48 @@ const messages = defineMessages({
|
||||
activeProfile: 'Active Profile',
|
||||
addradarr: 'Add Radarr 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 {
|
||||
name: string;
|
||||
isDefault?: boolean;
|
||||
@@ -249,51 +289,57 @@ const SettingsServices: React.FC = () => {
|
||||
<div className="mt-6 sm:mt-5">
|
||||
{!radarrData && !radarrError && <LoadingSpinner />}
|
||||
{radarrData && !radarrError && (
|
||||
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{radarrData.map((radarr) => (
|
||||
<ServerInstance
|
||||
key={`radarr-config-${radarr.id}`}
|
||||
name={radarr.name}
|
||||
address={radarr.hostname}
|
||||
profileName={radarr.activeProfileName}
|
||||
isSSL={radarr.useSsl}
|
||||
isDefault={radarr.isDefault && !radarr.is4k}
|
||||
isDefault4K={radarr.is4k && radarr.isDefault}
|
||||
onEdit={() => setEditRadarrModal({ open: true, radarr })}
|
||||
onDelete={() =>
|
||||
setDeleteServerModal({
|
||||
open: true,
|
||||
serverId: radarr.id,
|
||||
type: 'radarr',
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() =>
|
||||
setEditRadarrModal({ open: true, radarr: null })
|
||||
<>
|
||||
{radarrData.length > 0 &&
|
||||
!radarrData.some(
|
||||
(radarr) => radarr.isDefault && !radarr.is4k
|
||||
) && <NoDefaultAlert />}
|
||||
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{radarrData.map((radarr) => (
|
||||
<ServerInstance
|
||||
key={`radarr-config-${radarr.id}`}
|
||||
name={radarr.name}
|
||||
address={radarr.hostname}
|
||||
profileName={radarr.activeProfileName}
|
||||
isSSL={radarr.useSsl}
|
||||
isDefault={radarr.isDefault && !radarr.is4k}
|
||||
isDefault4K={radarr.is4k && radarr.isDefault}
|
||||
onEdit={() => setEditRadarrModal({ open: true, radarr })}
|
||||
onDelete={() =>
|
||||
setDeleteServerModal({
|
||||
open: true,
|
||||
serverId: radarr.id,
|
||||
type: 'radarr',
|
||||
})
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
/>
|
||||
))}
|
||||
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() =>
|
||||
setEditRadarrModal({ open: true, radarr: null })
|
||||
}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
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"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.addradarr} />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="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"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.addradarr} />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-10">
|
||||
@@ -307,52 +353,58 @@ const SettingsServices: React.FC = () => {
|
||||
<div className="mt-6 sm:mt-5">
|
||||
{!sonarrData && !sonarrError && <LoadingSpinner />}
|
||||
{sonarrData && !sonarrError && (
|
||||
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sonarrData.map((sonarr) => (
|
||||
<ServerInstance
|
||||
key={`sonarr-config-${sonarr.id}`}
|
||||
name={sonarr.name}
|
||||
address={sonarr.hostname}
|
||||
profileName={sonarr.activeProfileName}
|
||||
isSSL={sonarr.useSsl}
|
||||
isSonarr
|
||||
isDefault4K={sonarr.isDefault && sonarr.is4k}
|
||||
isDefault={sonarr.isDefault && !sonarr.is4k}
|
||||
onEdit={() => setEditSonarrModal({ open: true, sonarr })}
|
||||
onDelete={() =>
|
||||
setDeleteServerModal({
|
||||
open: true,
|
||||
serverId: sonarr.id,
|
||||
type: 'sonarr',
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() =>
|
||||
setEditSonarrModal({ open: true, sonarr: null })
|
||||
<>
|
||||
{sonarrData.length > 0 &&
|
||||
!sonarrData.some(
|
||||
(sonarr) => sonarr.isDefault && !sonarr.is4k
|
||||
) && <NoDefaultAlert />}
|
||||
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sonarrData.map((sonarr) => (
|
||||
<ServerInstance
|
||||
key={`sonarr-config-${sonarr.id}`}
|
||||
name={sonarr.name}
|
||||
address={sonarr.hostname}
|
||||
profileName={sonarr.activeProfileName}
|
||||
isSSL={sonarr.useSsl}
|
||||
isSonarr
|
||||
isDefault4K={sonarr.isDefault && sonarr.is4k}
|
||||
isDefault={sonarr.isDefault && !sonarr.is4k}
|
||||
onEdit={() => setEditSonarrModal({ open: true, sonarr })}
|
||||
onDelete={() =>
|
||||
setDeleteServerModal({
|
||||
open: true,
|
||||
serverId: sonarr.id,
|
||||
type: 'sonarr',
|
||||
})
|
||||
}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
/>
|
||||
))}
|
||||
<li className="col-span-1 border-2 border-dashed border-gray-400 rounded-lg shadow h-32 sm:h-32">
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
onClick={() =>
|
||||
setEditSonarrModal({ open: true, sonarr: null })
|
||||
}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
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"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.addsonarr} />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<svg
|
||||
className="w-5 h-5 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="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"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.addsonarr} />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -40,6 +40,10 @@ const messages = defineMessages({
|
||||
server4k: '4K Server',
|
||||
selectQualityProfile: 'Select a Quality Profile',
|
||||
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 {
|
||||
@@ -225,6 +229,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
handleSubmit,
|
||||
setFieldValue,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
@@ -257,7 +262,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
secondaryDisabled={
|
||||
!values.apiKey || !values.hostname || !values.port || isTesting
|
||||
}
|
||||
okDisabled={!isValidated || isSubmitting || isTesting}
|
||||
okDisabled={!isValidated || isSubmitting || isTesting || !isValid}
|
||||
onOk={() => handleSubmit()}
|
||||
title={
|
||||
!sonarr
|
||||
@@ -319,6 +324,9 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
</label>
|
||||
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||
<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
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
@@ -328,7 +336,7 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
setIsValidated(false);
|
||||
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>
|
||||
{errors.hostname && touched.hostname && (
|
||||
@@ -449,10 +457,17 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
as="select"
|
||||
id="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="">
|
||||
{intl.formatMessage(messages.selectQualityProfile)}
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingprofiles)
|
||||
: !isValidated
|
||||
? intl.formatMessage(
|
||||
messages.testFirstQualityProfiles
|
||||
)
|
||||
: intl.formatMessage(messages.selectQualityProfile)}
|
||||
</option>
|
||||
{testResponse.profiles.length > 0 &&
|
||||
testResponse.profiles.map((profile) => (
|
||||
@@ -485,10 +500,15 @@ const SonarrModal: React.FC<SonarrModalProps> = ({
|
||||
as="select"
|
||||
id="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="">
|
||||
{intl.formatMessage(messages.selectRootFolder)}
|
||||
{isTesting
|
||||
? intl.formatMessage(messages.loadingrootfolders)
|
||||
: !isValidated
|
||||
? intl.formatMessage(messages.testFirstRootFolders)
|
||||
: intl.formatMessage(messages.selectRootFolder)}
|
||||
</option>
|
||||
{testResponse.rootFolders.length > 0 &&
|
||||
testResponse.rootFolders.map((folder) => (
|
||||
|
||||
@@ -21,7 +21,7 @@ interface TitleCardProps {
|
||||
id: number;
|
||||
image?: string;
|
||||
summary?: string;
|
||||
year: string;
|
||||
year?: string;
|
||||
title: string;
|
||||
userScore: number;
|
||||
mediaType: MediaType;
|
||||
@@ -169,7 +169,7 @@ const TitleCard: React.FC<TitleCardProps> = ({
|
||||
>
|
||||
<div className="absolute bottom-0 w-full left-0 right-0">
|
||||
<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">
|
||||
{title}
|
||||
|
||||
@@ -227,8 +227,12 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-4xl">
|
||||
{data.name}{' '}
|
||||
<span className="text-2xl">({data.firstAirDate.slice(0, 4)})</span>
|
||||
<span>{data.name}</span>
|
||||
{data.firstAirDate && (
|
||||
<span className="text-2xl ml-2">
|
||||
({data.firstAirDate.slice(0, 4)})
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<span className="text-xs md:text-base mt-1 md:mt-0">
|
||||
{data.genres.map((g) => g.name).join(', ')}
|
||||
|
||||
@@ -116,6 +116,8 @@
|
||||
"components.Settings.RadarrModal.defaultserver": "Default Server",
|
||||
"components.Settings.RadarrModal.editradarr": "Edit Radarr Server",
|
||||
"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.port": "Port",
|
||||
"components.Settings.RadarrModal.qualityprofile": "Quality Profile",
|
||||
@@ -130,11 +132,14 @@
|
||||
"components.Settings.RadarrModal.servernamePlaceholder": "A Radarr Server",
|
||||
"components.Settings.RadarrModal.ssl": "SSL",
|
||||
"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.toastRadarrTestFailure": "Failed to connect to Radarr Server",
|
||||
"components.Settings.RadarrModal.toastRadarrTestSuccess": "Radarr connection established!",
|
||||
"components.Settings.RadarrModal.validationApiKeyRequired": "You must provide an API key",
|
||||
"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.validationPortRequired": "You must provide a port",
|
||||
"components.Settings.RadarrModal.validationProfileRequired": "You must select a profile",
|
||||
@@ -155,6 +160,8 @@
|
||||
"components.Settings.SonarrModal.defaultserver": "Default Server",
|
||||
"components.Settings.SonarrModal.editsonarr": "Edit Sonarr Server",
|
||||
"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.qualityprofile": "Quality Profile",
|
||||
"components.Settings.SonarrModal.rootfolder": "Root Folder",
|
||||
@@ -168,6 +175,8 @@
|
||||
"components.Settings.SonarrModal.servernamePlaceholder": "A Sonarr Server",
|
||||
"components.Settings.SonarrModal.ssl": "SSL",
|
||||
"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.toastRadarrTestFailure": "Could not connect to Sonarr Server",
|
||||
"components.Settings.SonarrModal.toastRadarrTestSuccess": "Sonarr connection established!",
|
||||
@@ -206,6 +215,9 @@
|
||||
"components.Settings.menuPlexSettings": "Plex",
|
||||
"components.Settings.menuServices": "Services",
|
||||
"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.notificationsettingsDescription": "Here you can pick and choose what types of notifications to send and through what types of services.",
|
||||
"components.Settings.notrunning": "Not Running",
|
||||
|
||||
@@ -7,7 +7,7 @@ body {
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user