added Support for Jellyfin Media Server

This commit is contained in:
Juan D. Jara
2021-09-27 02:56:02 +02:00
committed by notfakie
parent 5125abdbf0
commit 3661eea8bb
36 changed files with 2862 additions and 86 deletions

View File

@@ -1,12 +1,15 @@
import React from 'react';
import { MediaType } from '../../../server/constants/media';
import { MediaServerType } from '../../../server/constants/server';
import ImdbLogo from '../../assets/services/imdb.svg';
import JellyfinLogo from '../../assets/services/jellyfin.svg';
import PlexLogo from '../../assets/services/plex.svg';
import RTLogo from '../../assets/services/rt.svg';
import TmdbLogo from '../../assets/services/tmdb.svg';
import TraktLogo from '../../assets/services/trakt.svg';
import TvdbLogo from '../../assets/services/tvdb.svg';
import useLocale from '../../hooks/useLocale';
import useSettings from '../../hooks/useSettings';
interface ExternalLinkBlockProps {
mediaType: 'movie' | 'tv';
@@ -14,7 +17,7 @@ interface ExternalLinkBlockProps {
tvdbId?: number;
imdbId?: string;
rtUrl?: string;
plexUrl?: string;
mediaUrl?: string;
}
const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
@@ -23,20 +26,25 @@ const ExternalLinkBlock: React.FC<ExternalLinkBlockProps> = ({
tvdbId,
imdbId,
rtUrl,
plexUrl,
mediaUrl,
}) => {
const settings = useSettings();
const { locale } = useLocale();
return (
<div className="flex w-full items-center justify-center space-x-5">
{plexUrl && (
{mediaUrl && (
<a
href={plexUrl}
href={mediaUrl}
className="w-12 opacity-50 transition duration-300 hover:opacity-100"
target="_blank"
rel="noreferrer"
>
<PlexLogo />
{settings.currentSettings.mediaServerType === MediaServerType.PLEX ? (
<PlexLogo />
) : (
<JellyfinLogo />
)}
</a>
)}
{tmdbId && (

View File

@@ -352,10 +352,10 @@ const IssueDetails: React.FC = () => {
</div>
</div>
<div className="mt-4 mb-6 flex flex-col space-y-2">
{issueData?.media.plexUrl && (
{issueData?.media.mediaUrl && (
<Button
as="a"
href={issueData?.media.plexUrl}
href={issueData?.media.mediaUrl}
target="_blank"
rel="noreferrer"
className="w-full"
@@ -385,10 +385,10 @@ const IssueDetails: React.FC = () => {
</span>
</Button>
)}
{issueData?.media.plexUrl4k && (
{issueData?.media.mediaUrl4k && (
<Button
as="a"
href={issueData?.media.plexUrl4k}
href={issueData?.media.mediaUrl4k}
target="_blank"
rel="noreferrer"
className="w-full"
@@ -588,10 +588,10 @@ const IssueDetails: React.FC = () => {
</div>
</div>
<div className="mt-4 mb-6 flex flex-col space-y-2">
{issueData?.media.plexUrl && (
{issueData?.media.mediaUrl && (
<Button
as="a"
href={issueData?.media.plexUrl}
href={issueData?.media.mediaUrl}
target="_blank"
rel="noreferrer"
className="w-full"
@@ -621,10 +621,10 @@ const IssueDetails: React.FC = () => {
</span>
</Button>
)}
{issueData?.media.plexUrl4k && (
{issueData?.media.mediaUrl4k && (
<Button
as="a"
href={issueData?.media.plexUrl4k}
href={issueData?.media.mediaUrl4k}
target="_blank"
rel="noreferrer"
className="w-full"

View File

@@ -0,0 +1,114 @@
import React from 'react';
import Transition from '../Transition';
import Modal from '../Common/Modal';
import { Formik, Field } from 'formik';
import * as Yup from 'yup';
import axios from 'axios';
import { defineMessages, useIntl } from 'react-intl';
import useSettings from '../../hooks/useSettings';
const messages = defineMessages({
title: 'Add Email',
description:
'Since this is your first time logging into {applicationName}, you are required to add a valid email address.',
email: 'Email address',
validationEmailRequired: 'You must provide an email',
validationEmailFormat: 'Invalid email',
saving: 'Adding…',
save: 'Add',
});
interface AddEmailModalProps {
username: string;
password: string;
onClose: () => void;
onSave: () => void;
}
const AddEmailModal: React.FC<AddEmailModalProps> = ({
onClose,
username,
password,
onSave,
}) => {
const intl = useIntl();
const settings = useSettings();
const EmailSettingsSchema = Yup.object().shape({
email: Yup.string()
.email(intl.formatMessage(messages.validationEmailFormat))
.required(intl.formatMessage(messages.validationEmailRequired)),
});
return (
<Transition
appear
show
enter="transition ease-in-out duration-300 transform opacity-0"
enterFrom="opacity-0"
enterTo="opacuty-100"
leave="transition ease-in-out duration-300 transform opacity-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Formik
initialValues={{
email: '',
}}
validationSchema={EmailSettingsSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/auth/jellyfin', {
username: username,
password: password,
email: values.email,
});
onSave();
} catch (e) {
// set error here
}
}}
>
{({ errors, touched, handleSubmit, isSubmitting, isValid }) => {
return (
<Modal
onCancel={onClose}
okButtonType="primary"
okText={
isSubmitting
? intl.formatMessage(messages.saving)
: intl.formatMessage(messages.save)
}
okDisabled={isSubmitting || !isValid}
onOk={() => handleSubmit()}
title={intl.formatMessage(messages.title)}
>
{intl.formatMessage(messages.description, {
applicationName: settings.currentSettings.applicationTitle,
})}
<label htmlFor="email" className="text-label">
{intl.formatMessage(messages.email)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<Field
id="email"
name="email"
type="text"
placeholder={intl.formatMessage(messages.email)}
/>
</div>
{errors.email && touched.email && (
<div className="error">{errors.email}</div>
)}
</div>
</Modal>
);
}}
</Formik>
</Transition>
);
};
export default AddEmailModal;

View File

@@ -0,0 +1,313 @@
import React, { useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Button from '../Common/Button';
import { Field, Form, Formik } from 'formik';
import * as Yup from 'yup';
import axios from 'axios';
import { useToasts } from 'react-toast-notifications';
import useSettings from '../../hooks/useSettings';
import AddEmailModal from './AddEmailModal';
const messages = defineMessages({
username: 'Username',
password: 'Password',
host: 'Jellyfin URL',
email: 'Email',
validationhostrequired: 'Jellyfin URL required',
validationhostformat: 'Valid URL required',
validationemailrequired: 'Email required',
validationemailformat: 'Valid email required',
validationusernamerequired: 'Username required',
validationpasswordrequired: 'Password required',
loginerror: 'Something went wrong while trying to sign in.',
credentialerror: 'The username or password is incorrect.',
signingin: 'Signing in…',
signin: 'Sign In',
initialsigningin: 'Connecting…',
initialsignin: 'Connect',
forgotpassword: 'Forgot Password?',
});
interface JellyfinLoginProps {
revalidate: () => void;
initial?: boolean;
}
const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
revalidate,
initial,
}) => {
const [requiresEmail, setRequiresEmail] = useState<number>(0);
const [username, setUsername] = useState<string>();
const [password, setPassword] = useState<string>();
const toasts = useToasts();
const intl = useIntl();
const settings = useSettings();
if (initial) {
const LoginSchema = Yup.object().shape({
host: Yup.string()
.url(intl.formatMessage(messages.validationhostformat))
.required(intl.formatMessage(messages.validationhostrequired)),
email: Yup.string()
.email(intl.formatMessage(messages.validationemailformat))
.required(intl.formatMessage(messages.validationemailrequired)),
username: Yup.string().required(
intl.formatMessage(messages.validationusernamerequired)
),
password: Yup.string().required(
intl.formatMessage(messages.validationpasswordrequired)
),
});
return (
<Formik
initialValues={{
username: '',
password: '',
host: '',
email: '',
}}
validationSchema={LoginSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/auth/jellyfin', {
username: values.username,
password: values.password,
hostname: values.host,
email: values.email,
});
} catch (e) {
toasts.addToast(
intl.formatMessage(
e.message == 'Request failed with status code 401'
? messages.credentialerror
: messages.loginerror
),
{
autoDismiss: true,
appearance: 'error',
}
);
} finally {
revalidate();
}
}}
>
{({ errors, touched, isSubmitting, isValid }) => (
<Form>
<div className="sm:border-t sm:border-gray-800">
<label htmlFor="host" className="text-label">
{intl.formatMessage(messages.host)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<Field
id="host"
name="host"
type="text"
placeholder={intl.formatMessage(messages.host)}
/>
</div>
{errors.host && touched.host && (
<div className="error">{errors.host}</div>
)}
</div>
<label htmlFor="email" className="text-label">
{intl.formatMessage(messages.email)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<Field
id="email"
name="email"
type="text"
placeholder={intl.formatMessage(messages.email)}
/>
</div>
{errors.email && touched.email && (
<div className="error">{errors.email}</div>
)}
</div>
<label htmlFor="username" className="text-label">
{intl.formatMessage(messages.username)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex rounded-md shadow-sm">
<Field
id="username"
name="username"
type="text"
placeholder={intl.formatMessage(messages.username)}
/>
</div>
{errors.username && touched.username && (
<div className="error">{errors.username}</div>
)}
</div>
<label htmlFor="password" className="text-label">
{intl.formatMessage(messages.password)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flexrounded-md shadow-sm">
<Field
id="password"
name="password"
type="password"
placeholder={intl.formatMessage(messages.password)}
/>
</div>
{errors.password && touched.password && (
<div className="error">{errors.password}</div>
)}
</div>
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex justify-end">
<span className="inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
{isSubmitting
? intl.formatMessage(messages.signingin)
: intl.formatMessage(messages.signin)}
</Button>
</span>
</div>
</div>
</Form>
)}
</Formik>
);
} else {
const LoginSchema = Yup.object().shape({
username: Yup.string().required(
intl.formatMessage(messages.validationusernamerequired)
),
password: Yup.string().required(
intl.formatMessage(messages.validationpasswordrequired)
),
});
return (
<div>
{requiresEmail == 1 && (
<AddEmailModal
username={username ?? ''}
password={password ?? ''}
onSave={revalidate}
onClose={() => setRequiresEmail(0)}
></AddEmailModal>
)}
<Formik
initialValues={{
username: '',
password: '',
}}
validationSchema={LoginSchema}
onSubmit={async (values) => {
try {
await axios.post('/api/v1/auth/jellyfin', {
username: values.username,
password: values.password,
});
} catch (e) {
if (e.message === 'Request failed with status code 406') {
setUsername(values.username);
setPassword(values.password);
setRequiresEmail(1);
} else {
toasts.addToast(
intl.formatMessage(
e.message == 'Request failed with status code 401'
? messages.credentialerror
: messages.loginerror
),
{
autoDismiss: true,
appearance: 'error',
}
);
}
} finally {
revalidate();
}
}}
>
{({ errors, touched, isSubmitting, isValid }) => {
return (
<>
<Form>
<div className="sm:border-t sm:border-gray-800">
<label htmlFor="username" className="text-label">
{intl.formatMessage(messages.username)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="username"
name="username"
type="text"
placeholder={intl.formatMessage(messages.username)}
/>
</div>
{errors.username && touched.username && (
<div className="error">{errors.username}</div>
)}
</div>
<label htmlFor="password" className="text-label">
{intl.formatMessage(messages.password)}
</label>
<div className="mt-1 mb-2 sm:col-span-2 sm:mt-0">
<div className="flex max-w-lg rounded-md shadow-sm">
<Field
id="password"
name="password"
type="password"
placeholder={intl.formatMessage(messages.password)}
/>
</div>
{errors.password && touched.password && (
<div className="error">{errors.password}</div>
)}
</div>
</div>
<div className="mt-8 border-t border-gray-700 pt-5">
<div className="flex justify-between">
<span className="inline-flex rounded-md shadow-sm">
<Button
as="a"
buttonType="ghost"
href={
settings.currentSettings.jellyfinHost +
'/web/#!/forgotpassword.html'
}
>
{intl.formatMessage(messages.forgotpassword)}
</Button>
</span>
<span className="inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
type="submit"
disabled={isSubmitting || !isValid}
>
{isSubmitting
? intl.formatMessage(messages.signingin)
: intl.formatMessage(messages.signin)}
</Button>
</span>
</div>
</div>
</Form>
</>
);
}}
</Formik>
</div>
);
}
};
export default JellyfinLogin;

View File

@@ -4,6 +4,7 @@ import { useRouter } from 'next/dist/client/router';
import React, { useEffect, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import useSWR from 'swr';
import { MediaServerType } from '../../../server/constants/server';
import useSettings from '../../hooks/useSettings';
import { useUser } from '../../hooks/useUser';
import Accordion from '../Common/Accordion';
@@ -12,12 +13,14 @@ import PageTitle from '../Common/PageTitle';
import LanguagePicker from '../Layout/LanguagePicker';
import PlexLoginButton from '../PlexLoginButton';
import Transition from '../Transition';
import JellyfinLogin from './JellyfinLogin';
import LocalLogin from './LocalLogin';
const messages = defineMessages({
signin: 'Sign In',
signinheader: 'Sign in to continue',
signinwithplex: 'Use your Plex account',
signinwithjellyfin: 'Use your Jellyfin account',
signinwithoverseerr: 'Use your {applicationTitle} account',
});
@@ -127,14 +130,22 @@ const Login: React.FC = () => {
onClick={() => handleClick(0)}
disabled={!settings.currentSettings.localLogin}
>
{intl.formatMessage(messages.signinwithplex)}
{settings.currentSettings.mediaServerType ==
MediaServerType.PLEX
? intl.formatMessage(messages.signinwithplex)
: intl.formatMessage(messages.signinwithjellyfin)}
</button>
<AccordionContent isOpen={openIndexes.includes(0)}>
<div className="px-10 py-8">
<PlexLoginButton
isProcessing={isProcessing}
onAuthToken={(authToken) => setAuthToken(authToken)}
/>
{settings.currentSettings.mediaServerType ==
MediaServerType.PLEX ? (
<PlexLoginButton
isProcessing={isProcessing}
onAuthToken={(authToken) => setAuthToken(authToken)}
/>
) : (
<JellyfinLogin revalidate={revalidate} />
)}
</div>
</AccordionContent>
{settings.currentSettings.localLogin && (

View File

@@ -22,6 +22,7 @@ import useSWR from 'swr';
import type { RTRating } from '../../../server/api/rottentomatoes';
import { IssueStatus } from '../../../server/constants/issue';
import { MediaStatus } from '../../../server/constants/media';
import { MediaServerType } from '../../../server/constants/server';
import type { MovieDetails as MovieDetailsType } from '../../../server/models/Movie';
import RTAudFresh from '../../assets/rt_aud_fresh.svg';
import RTAudRotten from '../../assets/rt_aud_rotten.svg';
@@ -64,8 +65,11 @@ const messages = defineMessages({
overviewunavailable: 'Overview unavailable.',
studio: '{studioCount, plural, one {Studio} other {Studios}}',
viewfullcrew: 'View Full Crew',
playonplex: 'Play on Plex',
play4konplex: 'Play in 4K on Plex',
openradarr: 'Open Movie in Radarr',
openradarr4k: 'Open Movie in 4K Radarr',
downloadstatus: 'Download Status',
play: 'Play on {mediaServerName}',
play4k: 'Play 4K on {mediaServerName}',
markavailable: 'Mark as Available',
mark4kavailable: 'Mark as Available in 4K',
showmore: 'Show More',
@@ -124,29 +128,29 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
const showAllStudios = data.productionCompanies.length <= minStudios + 1;
const mediaLinks: PlayButtonLink[] = [];
if (
data.mediaInfo?.plexUrl &&
hasPermission([Permission.REQUEST, Permission.REQUEST_MOVIE], {
type: 'or',
})
) {
if (data.mediaInfo?.mediaUrl) {
mediaLinks.push({
text: intl.formatMessage(messages.playonplex),
url: data.mediaInfo?.plexUrl,
text:
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
? intl.formatMessage(messages.play, { mediaServerName: 'Jellyfin' })
: intl.formatMessage(messages.play, { mediaServerName: 'Plex' }),
url: data.mediaInfo?.mediaUrl,
svg: <PlayIcon />,
});
}
if (
settings.currentSettings.movie4kEnabled &&
data.mediaInfo?.plexUrl4k &&
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_MOVIE], {
data.mediaInfo?.mediaUrl4k &&
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
type: 'or',
})
) {
mediaLinks.push({
text: intl.formatMessage(messages.play4konplex),
url: data.mediaInfo?.plexUrl4k,
text:
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
? intl.formatMessage(messages.play4k, { mediaServerName: 'Jellyfin' })
: intl.formatMessage(messages.play4k, { mediaServerName: 'Plex' }),
url: data.mediaInfo?.mediaUrl4k,
svg: <PlayIcon />,
});
}
@@ -291,7 +295,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="movie"
plexUrl={data.mediaInfo?.plexUrl}
plexUrl={data.mediaInfo?.mediaUrl}
/>
{settings.currentSettings.movie4kEnabled &&
hasPermission(
@@ -312,7 +316,7 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="movie"
plexUrl={data.mediaInfo?.plexUrl4k}
plexUrl={data.mediaInfo?.mediaUrl4k}
/>
)}
</div>
@@ -713,7 +717,9 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
tvdbId={data.externalIds.tvdbId}
imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url}
plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k}
mediaUrl={
data.mediaInfo?.mediaUrl ?? data.mediaInfo?.mediaUrl4k
}
/>
</div>
</div>

View File

@@ -299,7 +299,9 @@ const RequestCard: React.FC<RequestCardProps> = ({ request, onTitleData }) => {
tmdbId={requestData.media.tmdbId}
mediaType={requestData.type}
plexUrl={
requestData.media[requestData.is4k ? 'plexUrl4k' : 'plexUrl']
requestData.media[
requestData.is4k ? 'mediaUrl4k' : 'mediaUrl'
]
}
/>
)}

View File

@@ -302,7 +302,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
mediaType={requestData.type}
plexUrl={
requestData.media[
requestData.is4k ? 'plexUrl4k' : 'plexUrl'
requestData.is4k ? 'mediaUrl4k' : 'mediaUrl'
]
}
/>

View File

@@ -0,0 +1,285 @@
import axios from 'axios';
import React, { useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import useSWR from 'swr';
import type { JellyfinSettings } from '../../../server/lib/settings';
import Badge from '../Common/Badge';
import Button from '../Common/Button';
import LoadingSpinner from '../Common/LoadingSpinner';
import LibraryItem from './LibraryItem';
const messages = defineMessages({
jellyfinsettings: 'Jellyfin Settings',
jellyfinsettingsDescription:
'Configure the settings for your Jellyfin server. Overseerr scans your Jellyfin libraries to see what content is available.',
timeout: 'Timeout',
save: 'Save Changes',
saving: 'Saving…',
jellyfinlibraries: 'Jellyfin Libraries',
jellyfinlibrariesDescription:
'The libraries Overseerr scans for titles. Click the button below if no libraries are listed.',
syncing: 'Syncing',
syncJellyfin: 'Sync Libraries',
manualscanJellyfin: 'Manual Library Scan',
manualscanDescriptionJellyfin:
"Normally, this will only be run once every 24 hours. Overseerr will check your Jellyfin server's recently added more aggressively. If this is your first time configuring Jellyfin, a one-time full manual library scan is recommended!",
notrunning: 'Not Running',
currentlibrary: 'Current Library: {name}',
librariesRemaining: 'Libraries Remaining: {count}',
startscan: 'Start Scan',
cancelscan: 'Cancel Scan',
});
interface Library {
id: string;
name: string;
enabled: boolean;
}
interface SyncStatus {
running: boolean;
progress: number;
total: number;
currentLibrary?: Library;
libraries: Library[];
}
interface SettingsJellyfinProps {
onComplete?: () => void;
}
const SettingsJellyfin: React.FC<SettingsJellyfinProps> = ({ onComplete }) => {
const [isSyncing, setIsSyncing] = useState(false);
const {
data: data,
error: error,
// revalidate: revalidate,
} = useSWR<JellyfinSettings>('/api/v1/settings/jellyfin');
const revalidate = () => undefined; //TODO
const revalidateSync = () => undefined; //TODO
const {
data: dataSync, //, revalidate: revalidateSync
} = useSWR<SyncStatus>('/api/v1/settings/jellyfin/sync', {
refreshInterval: 1000,
});
const intl = useIntl();
const activeLibraries =
data?.libraries
.filter((library) => library.enabled)
.map((library) => library.id) ?? [];
const syncLibraries = async () => {
setIsSyncing(true);
const params: { sync: boolean; enable?: string } = {
sync: true,
};
if (activeLibraries.length > 0) {
params.enable = activeLibraries.join(',');
}
await axios.get('/api/v1/settings/jellyfin/library', {
params,
});
setIsSyncing(false);
revalidate();
};
const startScan = async () => {
await axios.post('/api/v1/settings/jellyfin/sync', {
start: true,
});
revalidateSync();
};
const cancelScan = async () => {
await axios.post('/api/v1/settings/jellyfin/sync', {
cancel: true,
});
revalidateSync();
};
const toggleLibrary = async (libraryId: string) => {
setIsSyncing(true);
if (activeLibraries.includes(libraryId)) {
const params: { enable?: string } = {};
if (activeLibraries.length > 1) {
params.enable = activeLibraries
.filter((id) => id !== libraryId)
.join(',');
}
await axios.get('/api/v1/settings/jellyfin/library', {
params,
});
} else {
await axios.get('/api/v1/settings/jellyfin/library', {
params: {
enable: [...activeLibraries, libraryId].join(','),
},
});
}
if (onComplete) {
onComplete();
}
setIsSyncing(false);
revalidate();
};
if (!data && !error) {
return <LoadingSpinner />;
}
return (
<>
<div className="mb-6">
<h3 className="heading">
<FormattedMessage {...messages.jellyfinlibraries} />
</h3>
<p className="description">
<FormattedMessage {...messages.jellyfinlibrariesDescription} />
</p>
</div>
<div className="section">
<Button onClick={() => syncLibraries()} disabled={isSyncing}>
<svg
className={`${isSyncing ? 'animate-spin' : ''} mr-1 h-5 w-5`}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
clipRule="evenodd"
/>
</svg>
{isSyncing
? intl.formatMessage(messages.syncing)
: intl.formatMessage(messages.syncJellyfin)}
</Button>
<ul className="mt-6 grid grid-cols-1 gap-5 sm:grid-cols-2 sm:gap-6 lg:grid-cols-4">
{data?.libraries.map((library) => (
<LibraryItem
name={library.name}
isEnabled={library.enabled}
key={`setting-library-${library.id}`}
onToggle={() => toggleLibrary(library.id)}
/>
))}
</ul>
</div>
<div className="mt-10 mb-6">
<h3 className="heading">
<FormattedMessage {...messages.manualscanJellyfin} />
</h3>
<p className="description">
<FormattedMessage {...messages.manualscanDescriptionJellyfin} />
</p>
</div>
<div className="section">
<div className="rounded-md bg-gray-800 p-4">
<div className="relative mb-6 h-8 w-full overflow-hidden rounded-full bg-gray-600">
{dataSync?.running && (
<div
className="h-8 bg-indigo-600 transition-all duration-200 ease-in-out"
style={{
width: `${Math.round(
(dataSync.progress / dataSync.total) * 100
)}%`,
}}
/>
)}
<div className="absolute inset-0 flex h-8 w-full items-center justify-center text-sm">
<span>
{dataSync?.running
? `${dataSync.progress} of ${dataSync.total}`
: 'Not running'}
</span>
</div>
</div>
<div className="flex w-full flex-col sm:flex-row">
{dataSync?.running && (
<>
{dataSync.currentLibrary && (
<div className="mb-2 mr-0 flex items-center sm:mb-0 sm:mr-2">
<Badge>
<FormattedMessage
{...messages.currentlibrary}
values={{ name: dataSync.currentLibrary.name }}
/>
</Badge>
</div>
)}
<div className="flex items-center">
<Badge badgeType="warning">
<FormattedMessage
{...messages.librariesRemaining}
values={{
count: dataSync.currentLibrary
? dataSync.libraries.slice(
dataSync.libraries.findIndex(
(library) =>
library.id === dataSync.currentLibrary?.id
) + 1
).length
: 0,
}}
/>
</Badge>
</div>
</>
)}
<div className="flex-1 text-right">
{!dataSync?.running && (
<Button buttonType="warning" onClick={() => startScan()}>
<svg
className="mr-1 h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<FormattedMessage {...messages.startscan} />
</Button>
)}
{dataSync?.running && (
<Button buttonType="danger" onClick={() => cancelScan()}>
<svg
className="mr-1 h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<FormattedMessage {...messages.cancelscan} />
</Button>
)}
</div>
</div>
</div>
</div>
</>
);
};
export default SettingsJellyfin;

View File

@@ -8,6 +8,7 @@ const messages = defineMessages({
menuGeneralSettings: 'General',
menuUsers: 'Users',
menuPlexSettings: 'Plex',
menuJellyfinSettings: 'Jellyfin',
menuServices: 'Services',
menuNotifications: 'Notifications',
menuLogs: 'Logs',
@@ -34,6 +35,11 @@ const SettingsLayout: React.FC = ({ children }) => {
route: '/settings/plex',
regex: /^\/settings\/plex/,
},
{
text: intl.formatMessage(messages.menuJellyfinSettings),
route: '/settings/jellyfin',
regex: /^\/settings\/jellyfin/,
},
{
text: intl.formatMessage(messages.menuServices),
route: '/settings/services',

View File

@@ -0,0 +1,112 @@
import React, { useEffect, useState } from 'react';
import { useUser } from '../../hooks/useUser';
import PlexLoginButton from '../PlexLoginButton';
import JellyfinLogin from '../Login/JellyfinLogin';
import axios from 'axios';
import { defineMessages, FormattedMessage } from 'react-intl';
import Accordion from '../Common/Accordion';
import { MediaServerType } from '../../../server/constants/server';
const messages = defineMessages({
welcome: 'Welcome to Overseerr',
signinMessage: 'Get started by signing in',
signinWithJellyfin: 'Use your Jellyfin account',
signinWithPlex: 'Use your Plex account',
});
interface LoginWithMediaServerProps {
onComplete: () => void;
}
const SetupLogin: React.FC<LoginWithMediaServerProps> = ({ onComplete }) => {
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
const [mediaServerType, setMediaServerType] = useState<number>(
MediaServerType.NOT_CONFIGURED
);
const { user, revalidate } = useUser();
// Effect that is triggered when the `authToken` comes back from the Plex OAuth
// We take the token and attempt to login. If we get a success message, we will
// 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/plex', {
authToken: authToken,
});
if (response.data?.email) {
revalidate();
}
};
if (authToken && mediaServerType == MediaServerType.PLEX) {
login();
}
}, [authToken, mediaServerType, revalidate]);
useEffect(() => {
if (user) {
onComplete();
}
}, [user, onComplete]);
return (
<div>
<div className="mb-2 flex justify-center text-xl font-bold">
<FormattedMessage {...messages.welcome} />
</div>
<div className="mb-2 flex justify-center pb-6 text-sm">
<FormattedMessage {...messages.signinMessage} />
</div>
<Accordion single atLeastOne>
{({ openIndexes, handleClick, AccordionContent }) => (
<>
<button
className={`w-full cursor-default bg-gray-900 py-2 text-center text-sm text-gray-400 transition-colors duration-200 hover:cursor-pointer hover:bg-gray-700 focus:outline-none sm:rounded-t-lg ${
openIndexes.includes(0) && 'text-indigo-500'
} ${openIndexes.includes(1) && 'border-b border-gray-500'}`}
onClick={() => handleClick(0)}
>
<FormattedMessage {...messages.signinWithPlex} />
</button>
<AccordionContent isOpen={openIndexes.includes(0)}>
<div
className="px-10 py-8"
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
>
<PlexLoginButton
onAuthToken={(authToken) => {
setMediaServerType(MediaServerType.PLEX);
setAuthToken(authToken);
}}
/>
</div>
</AccordionContent>
<div>
<button
className={`w-full cursor-default bg-gray-900 py-2 text-center text-sm text-gray-400 transition-colors duration-200 hover:cursor-pointer hover:bg-gray-700 focus:outline-none ${
openIndexes.includes(1)
? 'text-indigo-500'
: 'sm:rounded-b-lg'
}`}
onClick={() => handleClick(1)}
>
<FormattedMessage {...messages.signinWithJellyfin} />
</button>
<AccordionContent isOpen={openIndexes.includes(1)}>
<div
className="rounded-b-lg px-10 py-8"
style={{ backgroundColor: 'rgba(0,0,0,0.3)' }}
>
<JellyfinLogin initial={true} revalidate={revalidate} />
</div>
</AccordionContent>
</div>
</>
)}
</Accordion>
</div>
);
};
export default SetupLogin;

View File

@@ -10,9 +10,10 @@ import Button from '../Common/Button';
import ImageFader from '../Common/ImageFader';
import PageTitle from '../Common/PageTitle';
import LanguagePicker from '../Layout/LanguagePicker';
import SettingsJellyfin from '../Settings/SettingsJellyfin';
import SettingsPlex from '../Settings/SettingsPlex';
import SettingsServices from '../Settings/SettingsServices';
import LoginWithPlex from './LoginWithPlex';
import SetupLogin from './SetupLogin';
import SetupSteps from './SetupSteps';
const messages = defineMessages({
@@ -20,8 +21,8 @@ const messages = defineMessages({
finish: 'Finish Setup',
finishing: 'Finishing…',
continue: 'Continue',
loginwithplex: 'Sign in with Plex',
configureplex: 'Configure Plex',
signin: 'Sign In',
configuremediaserver: 'Configure Media Server',
configureservices: 'Configure Services',
tip: 'Tip',
scanbackground:
@@ -32,7 +33,9 @@ const Setup: React.FC = () => {
const intl = useIntl();
const [isUpdating, setIsUpdating] = useState(false);
const [currentStep, setCurrentStep] = useState(1);
const [plexSettingsComplete, setPlexSettingsComplete] = useState(false);
const [mediaServerSettingsComplete, setMediaServerSettingsComplete] =
useState(false);
const [mediaServerType, setMediaServerType] = useState('');
const router = useRouter();
const { locale } = useLocale();
@@ -51,6 +54,11 @@ const Setup: React.FC = () => {
}
};
const getMediaServerType = async () => {
const MainSettings = await axios.get('/api/v1/settings/main');
setMediaServerType(MainSettings.data.mediaServerType);
return;
};
const { data: backdrops } = useSWR<string[]>('/api/v1/backdrops', {
refreshInterval: 0,
refreshWhenHidden: false,
@@ -84,13 +92,13 @@ const Setup: React.FC = () => {
>
<SetupSteps
stepNumber={1}
description={intl.formatMessage(messages.loginwithplex)}
description={intl.formatMessage(messages.signin)}
active={currentStep === 1}
completed={currentStep > 1}
/>
<SetupSteps
stepNumber={2}
description={intl.formatMessage(messages.configureplex)}
description={intl.formatMessage(messages.configuremediaserver)}
active={currentStep === 2}
completed={currentStep > 2}
/>
@@ -104,11 +112,25 @@ const Setup: React.FC = () => {
</nav>
<div className="mt-10 w-full rounded-md border border-gray-600 bg-gray-800 bg-opacity-50 p-4 text-white">
{currentStep === 1 && (
<LoginWithPlex onComplete={() => setCurrentStep(2)} />
<SetupLogin
onComplete={() => {
getMediaServerType().then(() => {
setCurrentStep(2);
});
}}
/>
)}
{currentStep === 2 && (
<div>
<SettingsPlex onComplete={() => setPlexSettingsComplete(true)} />
{mediaServerType == 'PLEX' ? (
<SettingsPlex
onComplete={() => setMediaServerSettingsComplete(true)}
/>
) : (
<SettingsJellyfin
onComplete={() => setMediaServerSettingsComplete(true)}
/>
)}
<div className="mt-4 text-sm text-gray-500">
<span className="mr-2">
<Badge>{intl.formatMessage(messages.tip)}</Badge>
@@ -120,7 +142,7 @@ const Setup: React.FC = () => {
<span className="ml-3 inline-flex rounded-md shadow-sm">
<Button
buttonType="primary"
disabled={!plexSettingsComplete}
disabled={!mediaServerSettingsComplete}
onClick={() => setCurrentStep(3)}
>
{intl.formatMessage(messages.continue)}

View File

@@ -117,28 +117,28 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
const mediaLinks: PlayButtonLink[] = [];
if (
data.mediaInfo?.plexUrl &&
data.mediaInfo?.mediaUrl &&
hasPermission([Permission.REQUEST, Permission.REQUEST_TV], {
type: 'or',
})
) {
mediaLinks.push({
text: intl.formatMessage(messages.playonplex),
url: data.mediaInfo?.plexUrl,
url: data.mediaInfo?.mediaUrl,
svg: <PlayIcon />,
});
}
if (
settings.currentSettings.series4kEnabled &&
data.mediaInfo?.plexUrl4k &&
data.mediaInfo?.mediaUrl4k &&
hasPermission([Permission.REQUEST_4K, Permission.REQUEST_4K_TV], {
type: 'or',
})
) {
mediaLinks.push({
text: intl.formatMessage(messages.play4konplex),
url: data.mediaInfo?.plexUrl4k,
url: data.mediaInfo?.mediaUrl4k,
svg: <PlayIcon />,
});
}
@@ -298,7 +298,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="tv"
plexUrl={data.mediaInfo?.plexUrl}
plexUrl={data.mediaInfo?.mediaUrl}
/>
{settings.currentSettings.series4kEnabled &&
hasPermission(
@@ -319,7 +319,7 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
}
tmdbId={data.mediaInfo?.tmdbId}
mediaType="tv"
plexUrl={data.mediaInfo?.plexUrl4k}
plexUrl={data.mediaInfo?.mediaUrl4k}
/>
)}
</div>
@@ -637,7 +637,9 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
tvdbId={data.externalIds.tvdbId}
imdbId={data.externalIds.imdbId}
rtUrl={ratingData?.url}
plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.plexUrl4k}
mediaUrl={
data.mediaInfo?.mediaUrl ?? data.mediaInfo?.mediaUrl4k
}
/>
</div>
</div>

View File

@@ -7,6 +7,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
import { MediaServerType } from '../../../../../server/constants/server';
import { UserSettingsGeneralResponse } from '../../../../../server/interfaces/api/userSettingsInterfaces';
import {
availableLanguages,
@@ -29,6 +30,9 @@ const messages = defineMessages({
general: 'General',
generalsettings: 'General Settings',
displayName: 'Display Name',
save: 'Save Changes',
saving: 'Saving…',
mediaServerUser: '{mediaServerName} User',
accounttype: 'Account Type',
plexuser: 'Plex User',
localuser: 'Local User',
@@ -55,6 +59,7 @@ const messages = defineMessages({
const UserGeneralSettings: React.FC = () => {
const intl = useIntl();
const settings = useSettings();
const { addToast } = useToasts();
const { locale, setLocale } = useLocale();
const [movieQuotaEnabled, setMovieQuotaEnabled] = useState(false);
@@ -184,11 +189,17 @@ const UserGeneralSettings: React.FC = () => {
<div className="flex max-w-lg items-center">
{user?.userType === UserType.PLEX ? (
<Badge badgeType="warning">
{intl.formatMessage(messages.plexuser)}
{intl.formatMessage(messages.localuser)}
</Badge>
) : (
<Badge badgeType="default">
{intl.formatMessage(messages.localuser)}
{intl.formatMessage(messages.mediaServerUser, {
mediaServerName:
settings.currentSettings.mediaServerType ===
MediaServerType.PLEX
? 'Plex'
: 'Jellyfin',
})}
</Badge>
)}
</div>