mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2026-01-01 04:08:45 -05:00
added Support for Jellyfin Media Server
This commit is contained in:
24
src/assets/services/jellyfin.svg
Normal file
24
src/assets/services/jellyfin.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg id="banner-dark" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1536 512">
|
||||
<defs>
|
||||
<linearGradient id="linear-gradient" x1="110.25" y1="213.3" x2="496.14" y2="436.09" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#aa5cc3"/>
|
||||
<stop offset="1" stop-color="#00a4dc"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<title>banner-dark</title>
|
||||
<g id="banner-dark">
|
||||
<g id="banner-dark-icon">
|
||||
<path id="inner-shape" d="M261.42,201.62c-20.44,0-86.24,119.29-76.2,139.43s142.48,19.92,152.4,0S281.86,201.63,261.42,201.62Z" fill="url(#linear-gradient)"/>
|
||||
<path id="outer-shape" d="M261.42,23.3C199.83,23.3,1.57,382.73,31.8,443.43s429.34,60,459.24,0S323,23.3,261.42,23.3ZM411.9,390.76c-19.59,39.33-281.08,39.77-300.9,0S221.1,115.48,261.45,115.48,431.49,351.42,411.9,390.76Z" fill="url(#linear-gradient)"/>
|
||||
</g>
|
||||
<g id="jellyfin-light-outlines" style="isolation:isolate" transform="translate(43.8)">
|
||||
<path d="M556.64,350.75a67,67,0,0,1-22.87-27.47,8.91,8.91,0,0,1-1.49-4.75,7.42,7.42,0,0,1,2.83-5.94,9.25,9.25,0,0,1,6.09-2.38c3.16,0,5.94,1.69,8.31,5.05a48.09,48.09,0,0,0,16.34,20.34,40.59,40.59,0,0,0,24,7.58q20.51,0,33.27-12.62t12.77-33.12V159a8.44,8.44,0,0,1,2.67-6.39,9.56,9.56,0,0,1,6.83-2.52,9,9,0,0,1,6.68,2.52,8.7,8.7,0,0,1,2.53,6.39v138.4a64.7,64.7,0,0,1-8.32,32.67,59,59,0,0,1-23,22.72Q608.62,361,589.9,361A57.21,57.21,0,0,1,556.64,350.75Z" fill="#fff"/>
|
||||
<path d="M831.66,279.47a8.77,8.77,0,0,1-6.24,2.53H713.16q0,17.82,7.27,31.92a54.91,54.91,0,0,0,20.79,22.28q13.51,8.18,31.93,8.17a54,54,0,0,0,25.54-5.94,52.7,52.7,0,0,0,18.12-15.15,10,10,0,0,1,6.24-2.67,8.14,8.14,0,0,1,7.72,7.72,8.81,8.81,0,0,1-3,6.24,74.7,74.7,0,0,1-23.91,19A65.56,65.56,0,0,1,773.45,361q-22.87,0-40.4-9.8a69.51,69.51,0,0,1-27.32-27.48q-9.79-17.66-9.8-40.83,0-24.36,9.65-42.62t25.69-27.92a65.2,65.2,0,0,1,34.16-9.65A70,70,0,0,1,798.84,211a65.78,65.78,0,0,1,25.39,24.36q9.81,16,10.1,38A8.07,8.07,0,0,1,831.66,279.47ZM733.5,231.8Q718.8,243.68,714.64,266H815.92v-2.38A46.91,46.91,0,0,0,807,240.27a48.47,48.47,0,0,0-18.56-15.15,54,54,0,0,0-23-5.2Q748.2,219.92,733.5,231.8Z" fill="#fff"/>
|
||||
<path d="M888.24,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,888.24,355.5Z" fill="#fff"/>
|
||||
<path d="M956.55,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,956.55,355.5Z" fill="#fff"/>
|
||||
<path d="M1122.86,206.11a8.7,8.7,0,0,1,2.53,6.39v131q0,23.44-9.21,40.09a61.58,61.58,0,0,1-25.54,25.25q-16.34,8.61-36.83,8.61a96.73,96.73,0,0,1-23.31-2.68,61.72,61.72,0,0,1-18-7.12q-6.24-3.87-6.24-8.62a17.94,17.94,0,0,1,.6-3,8.06,8.06,0,0,1,3-4.45,7.49,7.49,0,0,1,4.45-1.49,7.91,7.91,0,0,1,3.56.89q19,10.39,36.24,10.4,24.65,0,39.06-15.44t14.4-42.18V333.38a54.37,54.37,0,0,1-21.38,20,62.55,62.55,0,0,1-30.3,7.58q-25.83,0-39.2-15.45t-13.37-41.87V212.5a8.91,8.91,0,1,1,17.82,0V301q0,21.39,9.36,32.38t29.25,11a48,48,0,0,0,23.32-6.09,49.88,49.88,0,0,0,17.82-16,37.44,37.44,0,0,0,6.68-21.24V212.5a9,9,0,0,1,15.29-6.39Z" fill="#fff"/>
|
||||
<path d="M1210.18,161.41q-5.21,6.24-5.2,17.23v30.59h33.27a8.19,8.19,0,0,1,5.79,2.38,8.26,8.26,0,0,1,0,11.88,8.22,8.22,0,0,1-5.79,2.37H1205V349.12a8.91,8.91,0,1,1-17.82,0V225.86h-21.68a7.83,7.83,0,0,1-5.94-2.52,8.21,8.21,0,0,1-2.37-5.79,8,8,0,0,1,2.37-6.09,8.33,8.33,0,0,1,5.94-2.23h21.68V178.64q0-18.7,10.84-29t29-10.24a46.1,46.1,0,0,1,15.45,2.52q7.13,2.53,7.12,8.17a8.07,8.07,0,0,1-2.37,5.94,7.37,7.37,0,0,1-5.35,2.37,18.81,18.81,0,0,1-6.53-1.48,42,42,0,0,0-10.4-1.78Q1215.37,155.18,1210.18,161.41ZM1276,180.87c-2.19-1.88-3.27-4.61-3.27-8.17v-3q0-5.34,3.41-8.17t9.36-2.82q11.88,0,11.88,11v3c0,3.56-1,6.29-3.12,8.17s-5.1,2.82-9.06,2.82S1278.14,182.75,1276,180.87Zm15.59,174.63a8.92,8.92,0,0,1-15.3-6.38V212.5a8.91,8.91,0,1,1,17.82,0V349.12A8.65,8.65,0,0,1,1291.56,355.5Z" fill="#fff"/>
|
||||
<path d="M1452.53,218.88q12.92,16.2,12.92,42.92v87.32a8.4,8.4,0,0,1-2.67,6.38,8.8,8.8,0,0,1-6.24,2.53,8.64,8.64,0,0,1-8.91-8.91V262.69q0-19.31-9.65-31.33t-29.85-12a53.28,53.28,0,0,0-42.77,21.83,36.24,36.24,0,0,0-7.13,21.53v86.43a8.91,8.91,0,1,1-17.82,0V216.06a8.91,8.91,0,1,1,17.82,0V232.4q8-12.77,23-21.24A61.84,61.84,0,0,1,1412,202.7Q1439.61,202.7,1452.53,218.88Z" fill="#fff"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -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 && (
|
||||
|
||||
@@ -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"
|
||||
|
||||
114
src/components/Login/AddEmailModal.tsx
Normal file
114
src/components/Login/AddEmailModal.tsx
Normal 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;
|
||||
313
src/components/Login/JellyfinLogin.tsx
Normal file
313
src/components/Login/JellyfinLogin.tsx
Normal 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;
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
]
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -302,7 +302,7 @@ const RequestItem: React.FC<RequestItemProps> = ({
|
||||
mediaType={requestData.type}
|
||||
plexUrl={
|
||||
requestData.media[
|
||||
requestData.is4k ? 'plexUrl4k' : 'plexUrl'
|
||||
requestData.is4k ? 'mediaUrl4k' : 'mediaUrl'
|
||||
]
|
||||
}
|
||||
/>
|
||||
|
||||
285
src/components/Settings/SettingsJellyfin.tsx
Normal file
285
src/components/Settings/SettingsJellyfin.tsx
Normal 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;
|
||||
@@ -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',
|
||||
|
||||
112
src/components/Setup/SetupLogin.tsx
Normal file
112
src/components/Setup/SetupLogin.tsx
Normal 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;
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { MediaServerType } from '../../server/constants/server';
|
||||
import { PublicSettingsResponse } from '../../server/interfaces/api/settingsInterfaces';
|
||||
|
||||
export interface SettingsContextProps {
|
||||
@@ -16,6 +17,7 @@ const defaultSettings = {
|
||||
series4kEnabled: false,
|
||||
region: '',
|
||||
originalLanguage: '',
|
||||
mediaServerType: MediaServerType.NOT_CONFIGURED,
|
||||
partialRequestsEnabled: true,
|
||||
cacheImages: false,
|
||||
vapidPublic: '',
|
||||
|
||||
@@ -5,6 +5,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { ToastProvider } from 'react-toast-notifications';
|
||||
import { SWRConfig } from 'swr';
|
||||
import { MediaServerType } from '../../server/constants/server';
|
||||
import { PublicSettingsResponse } from '../../server/interfaces/api/settingsInterfaces';
|
||||
import Layout from '../components/Layout';
|
||||
import LoadingBar from '../components/LoadingBar';
|
||||
@@ -165,6 +166,7 @@ CoreApp.getInitialProps = async (initialProps) => {
|
||||
localLogin: true,
|
||||
region: '',
|
||||
originalLanguage: '',
|
||||
mediaServerType: MediaServerType.NOT_CONFIGURED,
|
||||
partialRequestsEnabled: true,
|
||||
cacheImages: false,
|
||||
vapidPublic: '',
|
||||
|
||||
17
src/pages/settings/jellyfin.tsx
Normal file
17
src/pages/settings/jellyfin.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import type { NextPage } from 'next';
|
||||
import SettingsLayout from '../../components/Settings/SettingsLayout';
|
||||
import SettingsJellyfin from '../../components/Settings/SettingsJellyfin';
|
||||
import { Permission } from '../../hooks/useUser';
|
||||
import useRouteGuard from '../../hooks/useRouteGuard';
|
||||
|
||||
const JellyfinSettingsPage: NextPage = () => {
|
||||
useRouteGuard(Permission.MANAGE_SETTINGS);
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<SettingsJellyfin />
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default JellyfinSettingsPage;
|
||||
50
src/utils/jellyfin.ts
Normal file
50
src/utils/jellyfin.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import axios, { AxiosError, AxiosResponse } from 'axios';
|
||||
|
||||
interface JellyfinAuthenticationResult {
|
||||
Id: string;
|
||||
AccessToken: string;
|
||||
ServerId: string;
|
||||
}
|
||||
|
||||
class JellyAPI {
|
||||
public login(
|
||||
Hostname?: string,
|
||||
Username?: string,
|
||||
Password?: string
|
||||
): Promise<JellyfinAuthenticationResult> {
|
||||
return new Promise(
|
||||
(
|
||||
resolve: (result: JellyfinAuthenticationResult) => void,
|
||||
reject: (e: Error) => void
|
||||
) => {
|
||||
axios
|
||||
.post(
|
||||
Hostname + '/Users/AuthenticateByName',
|
||||
{
|
||||
Username: Username,
|
||||
Pw: Password,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'X-Emby-Authorization':
|
||||
'MediaBrowser Client="Jellyfin Web", Device="Firefox", DeviceId="TW96aWxsYS81LjAgKFdpbmRvd3MgTlQgMTAuMDsgV2luNjQ7IHg2NDsgcnY6ODUuMCkgR2Vja28vMjAxMDAxMDEgRmlyZWZveC84NS4wfDE2MTI5MjcyMDM5NzM1", Version="10.8.0"',
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((resp: AxiosResponse) => {
|
||||
const response: JellyfinAuthenticationResult = {
|
||||
Id: resp.data.User.Id,
|
||||
AccessToken: resp.data.AccessToken,
|
||||
ServerId: resp.data.ServerId,
|
||||
};
|
||||
resolve(response);
|
||||
})
|
||||
.catch((e: AxiosError) => {
|
||||
reject(e);
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default JellyAPI;
|
||||
Reference in New Issue
Block a user