feat(all): add initial Jellyfin/Emby support
This commit is contained in:
269
src/components/Login/JellyfinLogin.tsx
Normal file
269
src/components/Login/JellyfinLogin.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import React 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';
|
||||
|
||||
const messages = defineMessages({
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
host: 'Jellyfin URL',
|
||||
validationhostrequired: 'Jellyfin URL required',
|
||||
validationhostformat: 'Valid URL 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 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)),
|
||||
username: Yup.string().required(
|
||||
intl.formatMessage(messages.validationusernamerequired)
|
||||
),
|
||||
password: Yup.string().required(
|
||||
intl.formatMessage(messages.validationpasswordrequired)
|
||||
),
|
||||
});
|
||||
return (
|
||||
<Formik
|
||||
initialValues={{
|
||||
username: '',
|
||||
password: '',
|
||||
host: '',
|
||||
}}
|
||||
validationSchema={LoginSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await axios.post('/api/v1/auth/jellyfin', {
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
hostname: values.host,
|
||||
});
|
||||
} 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:mt-0 sm:col-span-2">
|
||||
<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="username" className="text-label">
|
||||
{intl.formatMessage(messages.username)}
|
||||
</label>
|
||||
<div className="mt-1 mb-2 sm:mt-0 sm:col-span-2">
|
||||
<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:mt-0 sm:col-span-2">
|
||||
<div className="shadow-sm flexrounded-md">
|
||||
<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="pt-5 mt-8 border-t border-gray-700">
|
||||
<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 (
|
||||
<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) {
|
||||
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:mt-0 sm:col-span-2">
|
||||
<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:mt-0 sm:col-span-2">
|
||||
<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="pt-5 mt-8 border-t border-gray-700">
|
||||
<div className="flex justify-between">
|
||||
<span className="inline-flex rounded-md shadow-sm">
|
||||
<Button
|
||||
as="a"
|
||||
buttonType="ghost"
|
||||
href={
|
||||
settings.currentSettings.jfHost +
|
||||
'/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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default JellyfinLogin;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PlexLoginButton from '../PlexLoginButton';
|
||||
import JellyfinLogin from './JellyfinLogin';
|
||||
import { useUser } from '../../hooks/useUser';
|
||||
import axios from 'axios';
|
||||
import { useRouter } from 'next/dist/client/router';
|
||||
@@ -16,6 +17,7 @@ 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',
|
||||
});
|
||||
|
||||
@@ -134,14 +136,20 @@ const Login: React.FC = () => {
|
||||
onClick={() => handleClick(0)}
|
||||
disabled={!settings.currentSettings.localLogin}
|
||||
>
|
||||
{intl.formatMessage(messages.signinwithplex)}
|
||||
{settings.currentSettings.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 == 'PLEX' ? (
|
||||
<PlexLoginButton
|
||||
isProcessing={isProcessing}
|
||||
onAuthToken={(authToken) => setAuthToken(authToken)}
|
||||
/>
|
||||
) : (
|
||||
<JellyfinLogin revalidate={revalidate} />
|
||||
)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
{settings.currentSettings.localLogin && (
|
||||
|
||||
@@ -72,6 +72,8 @@ const messages = defineMessages({
|
||||
downloadstatus: 'Download Status',
|
||||
playonplex: 'Play on Plex',
|
||||
play4konplex: 'Play 4K on Plex',
|
||||
playonjellyfin: 'Play on Jellyfin',
|
||||
play4konjellyfin: 'Play 4K on Jellyfin',
|
||||
markavailable: 'Mark as Available',
|
||||
mark4kavailable: 'Mark 4K as Available',
|
||||
});
|
||||
@@ -385,11 +387,33 @@ const MovieDetails: React.FC<MovieDetailsProps> = ({ movie }) => {
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
|
||||
<div className="mb-2 space-x-2">
|
||||
<span className="ml-2 lg:ml-0">
|
||||
{data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && (
|
||||
<span className="ml-2 lg:ml-0">
|
||||
<StatusBadge
|
||||
status={data.mediaInfo?.status}
|
||||
inProgress={(data.mediaInfo.downloadStatus ?? []).length > 0}
|
||||
plexUrl={
|
||||
data.mediaInfo?.plexUrl ?? data.mediaInfo?.jellyfinUrl
|
||||
}
|
||||
plexUrl4k={
|
||||
data.mediaInfo?.plexUrl4k ?? data.mediaInfo?.jellyfinUrl4k
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
<StatusBadge
|
||||
status={data.mediaInfo?.status}
|
||||
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
|
||||
plexUrl={data.mediaInfo?.plexUrl}
|
||||
status={data.mediaInfo?.status4k}
|
||||
is4k
|
||||
inProgress={(data.mediaInfo?.downloadStatus4k ?? []).length > 0}
|
||||
plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.jellyfinUrl}
|
||||
plexUrl4k={
|
||||
data.mediaInfo?.plexUrl4k &&
|
||||
(hasPermission(Permission.REQUEST_4K) ||
|
||||
hasPermission(Permission.REQUEST_4K_MOVIE))
|
||||
? data.mediaInfo.plexUrl4k ?? data.mediaInfo?.jellyfinUrl4k
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
{settings.currentSettings.movie4kEnabled &&
|
||||
|
||||
@@ -3,7 +3,7 @@ import PlexOAuth from '../../utils/plex';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages({
|
||||
signinwithplex: 'Sign In',
|
||||
signinwithplex: 'Sign In with Plex',
|
||||
loading: 'Loading…',
|
||||
signingin: 'Signing in…',
|
||||
});
|
||||
|
||||
283
src/components/Settings/SettingsJellyfin.tsx
Normal file
283
src/components/Settings/SettingsJellyfin.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import React, { useState } from 'react';
|
||||
import LoadingSpinner from '../Common/LoadingSpinner';
|
||||
import type { JellyfinSettings } from '../../../server/lib/settings';
|
||||
import useSWR from 'swr';
|
||||
import Button from '../Common/Button';
|
||||
import axios from 'axios';
|
||||
import LibraryItem from './LibraryItem';
|
||||
import Badge from '../Common/Badge';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
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 { 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' : ''} w-5 h-5 mr-1`}
|
||||
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="grid grid-cols-1 gap-5 mt-6 sm:gap-6 sm:grid-cols-2 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="p-4 bg-gray-800 rounded-md">
|
||||
<div className="relative w-full h-8 mb-6 overflow-hidden bg-gray-600 rounded-full">
|
||||
{dataSync?.running && (
|
||||
<div
|
||||
className="h-8 transition-all duration-200 ease-in-out bg-indigo-600"
|
||||
style={{
|
||||
width: `${Math.round(
|
||||
(dataSync.progress / dataSync.total) * 100
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center w-full h-8 text-sm">
|
||||
<span>
|
||||
{dataSync?.running
|
||||
? `${dataSync.progress} of ${dataSync.total}`
|
||||
: 'Not running'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col w-full sm:flex-row">
|
||||
{dataSync?.running && (
|
||||
<>
|
||||
{dataSync.currentLibrary && (
|
||||
<div className="flex items-center mb-2 mr-0 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="w-5 h-5 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="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="w-5 h-5 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedMessage {...messages.cancelscan} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsJellyfin;
|
||||
@@ -8,6 +8,7 @@ const messages = defineMessages({
|
||||
settings: 'Settings',
|
||||
menuGeneralSettings: 'General Settings',
|
||||
menuPlexSettings: 'Plex',
|
||||
menuJellyfinSettings: 'Jellyfin',
|
||||
menuServices: 'Services',
|
||||
menuNotifications: 'Notifications',
|
||||
menuLogs: 'Logs',
|
||||
@@ -36,6 +37,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',
|
||||
|
||||
@@ -35,6 +35,7 @@ const messages = defineMessages({
|
||||
toastSettingsSuccess: 'Settings successfully saved!',
|
||||
toastSettingsFailure: 'Something went wrong while saving settings.',
|
||||
defaultPermissions: 'Default User Permissions',
|
||||
useJellyfin: 'Use Jellyfin as Media Server',
|
||||
hideAvailable: 'Hide Available Media',
|
||||
csrfProtection: 'Enable CSRF Protection',
|
||||
csrfProtectionTip:
|
||||
@@ -122,6 +123,7 @@ const SettingsMain: React.FC = () => {
|
||||
region: data?.region,
|
||||
originalLanguage: data?.originalLanguage,
|
||||
trustProxy: data?.trustProxy,
|
||||
useJellyfin: data?.mediaServerType == 'JELLYFIN' ? true : false,
|
||||
}}
|
||||
enableReinitialize
|
||||
validationSchema={MainSettingsSchema}
|
||||
@@ -137,6 +139,7 @@ const SettingsMain: React.FC = () => {
|
||||
region: values.region,
|
||||
originalLanguage: values.originalLanguage,
|
||||
trustProxy: values.trustProxy,
|
||||
mediaServerType: values.useJellyfin ? 'JELLYFIN' : 'PLEX',
|
||||
});
|
||||
|
||||
addToast(intl.formatMessage(messages.toastSettingsSuccess), {
|
||||
@@ -360,6 +363,21 @@ const SettingsMain: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<label htmlFor="useJellyfin" className="checkbox-label">
|
||||
<span>{intl.formatMessage(messages.useJellyfin)}</span>
|
||||
</label>
|
||||
<div className="form-input">
|
||||
<Field
|
||||
type="checkbox"
|
||||
id="useJellyfin"
|
||||
name="useJellyfin"
|
||||
onChange={() => {
|
||||
setFieldValue('useJellyfin', !values.useJellyfin);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby="group-label"
|
||||
|
||||
89
src/components/Setup/SetupLogin.tsx
Normal file
89
src/components/Setup/SetupLogin.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
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 LoadingSpinner from '../Common/LoadingSpinner';
|
||||
|
||||
const messages = defineMessages({
|
||||
welcome: 'Welcome to Overseerr',
|
||||
signinMessage: 'Get started by logging in with an account',
|
||||
signinWithJellyfin: 'Use Jellyfin',
|
||||
});
|
||||
|
||||
interface LoginWithMediaServerProps {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
const SetupLogin: React.FC<LoginWithMediaServerProps> = ({ onComplete }) => {
|
||||
const [authToken, setAuthToken] = useState<string | undefined>(undefined);
|
||||
const [mediaServerType, setMediaServerType] = useState<string>('');
|
||||
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/login', {
|
||||
authToken: authToken,
|
||||
});
|
||||
|
||||
if (response.data?.email) {
|
||||
revalidate();
|
||||
}
|
||||
};
|
||||
if (authToken && mediaServerType == 'PLEX') {
|
||||
login();
|
||||
}
|
||||
}, [authToken, mediaServerType, revalidate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
onComplete();
|
||||
}
|
||||
}, [user, onComplete]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{mediaServerType == '' ? (
|
||||
<React.Fragment>
|
||||
<div className="flex justify-center mb-2 text-xl font-bold">
|
||||
<FormattedMessage {...messages.welcome} />
|
||||
</div>
|
||||
<div className="flex justify-center pb-6 mb-2 text-sm">
|
||||
<FormattedMessage {...messages.signinMessage} />
|
||||
</div>
|
||||
<div className="flex items-center justify-center pb-4">
|
||||
<PlexLoginButton
|
||||
onAuthToken={(authToken) => {
|
||||
setMediaServerType('PLEX');
|
||||
setAuthToken(authToken);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<hr className="m-auto border-gray-600 w-60"></hr>
|
||||
<span className="block w-full rounded-md shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMediaServerType('JELLYFIN');
|
||||
}}
|
||||
className="jellyfin-button"
|
||||
>
|
||||
<FormattedMessage {...messages.signinWithJellyfin} />
|
||||
</button>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
) : mediaServerType == 'JELLYFIN' ? (
|
||||
<JellyfinLogin initial={true} revalidate={revalidate} />
|
||||
) : (
|
||||
<LoadingSpinner></LoadingSpinner>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupLogin;
|
||||
@@ -3,8 +3,9 @@ import React, { useState } from 'react';
|
||||
import Button from '../Common/Button';
|
||||
import ImageFader from '../Common/ImageFader';
|
||||
import SettingsPlex from '../Settings/SettingsPlex';
|
||||
import SettingsJellyfin from '../Settings/SettingsJellyfin';
|
||||
import SettingsServices from '../Settings/SettingsServices';
|
||||
import LoginWithPlex from './LoginWithPlex';
|
||||
import SetupLogin from './SetupLogin';
|
||||
import SetupSteps from './SetupSteps';
|
||||
import axios from 'axios';
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
@@ -18,8 +19,8 @@ const messages = defineMessages({
|
||||
finish: 'Finish Setup',
|
||||
finishing: 'Finishing…',
|
||||
continue: 'Continue',
|
||||
loginwithplex: 'Login with Plex',
|
||||
configureplex: 'Configure Plex',
|
||||
authorize: 'Authorize',
|
||||
connectmediaserver: 'Connect Media Server',
|
||||
configureservices: 'Configure Services',
|
||||
tip: 'Tip',
|
||||
syncingbackground:
|
||||
@@ -30,7 +31,8 @@ const Setup: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [plexSettingsComplete, setPlexSettingsComplete] = useState(false);
|
||||
const [msSettingsComplete, setMSSettingsComplete] = useState(false);
|
||||
const [mediaServerType, setMediaServerType] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
const finishSetup = async () => {
|
||||
@@ -45,6 +47,12 @@ const Setup: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getMediaServerType = async () => {
|
||||
const MainSettings = await axios.get('/api/v1/settings/main');
|
||||
setMediaServerType(MainSettings.data.mediaServerType);
|
||||
return;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col justify-center min-h-screen py-12 bg-gray-900">
|
||||
<PageTitle title={intl.formatMessage(messages.setup)} />
|
||||
@@ -75,13 +83,13 @@ const Setup: React.FC = () => {
|
||||
>
|
||||
<SetupSteps
|
||||
stepNumber={1}
|
||||
description={intl.formatMessage(messages.loginwithplex)}
|
||||
description={intl.formatMessage(messages.authorize)}
|
||||
active={currentStep === 1}
|
||||
completed={currentStep > 1}
|
||||
/>
|
||||
<SetupSteps
|
||||
stepNumber={2}
|
||||
description={intl.formatMessage(messages.configureplex)}
|
||||
description={intl.formatMessage(messages.connectmediaserver)}
|
||||
active={currentStep === 2}
|
||||
completed={currentStep > 2}
|
||||
/>
|
||||
@@ -95,11 +103,23 @@ const Setup: React.FC = () => {
|
||||
</nav>
|
||||
<div className="w-full p-4 mt-10 text-white bg-gray-800 bg-opacity-50 border border-gray-600 rounded-md">
|
||||
{currentStep === 1 && (
|
||||
<LoginWithPlex onComplete={() => setCurrentStep(2)} />
|
||||
<SetupLogin
|
||||
onComplete={() => {
|
||||
getMediaServerType().then(() => {
|
||||
setCurrentStep(2);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<div>
|
||||
<SettingsPlex onComplete={() => setPlexSettingsComplete(true)} />
|
||||
{mediaServerType == 'PLEX' ? (
|
||||
<SettingsPlex onComplete={() => setMSSettingsComplete(true)} />
|
||||
) : (
|
||||
<SettingsJellyfin
|
||||
onComplete={() => setMSSettingsComplete(true)}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-4 text-sm text-gray-500">
|
||||
<span className="mr-2">
|
||||
<Badge>{intl.formatMessage(messages.tip)}</Badge>
|
||||
@@ -111,7 +131,7 @@ const Setup: React.FC = () => {
|
||||
<span className="inline-flex ml-3 rounded-md shadow-sm">
|
||||
<Button
|
||||
buttonType="primary"
|
||||
disabled={!plexSettingsComplete}
|
||||
disabled={!msSettingsComplete}
|
||||
onClick={() => setCurrentStep(3)}
|
||||
>
|
||||
<FormattedMessage {...messages.continue} />
|
||||
|
||||
@@ -69,6 +69,8 @@ const messages = defineMessages({
|
||||
downloadstatus: 'Download Status',
|
||||
playonplex: 'Play on Plex',
|
||||
play4konplex: 'Play 4K on Plex',
|
||||
playonjellyfin: 'Play on Jellyfin',
|
||||
play4konjellyfin: 'Play 4K on Jellyfin',
|
||||
markavailable: 'Mark as Available',
|
||||
mark4kavailable: 'Mark 4K as Available',
|
||||
allseasonsmarkedavailable: '* All seasons will be marked as available.',
|
||||
@@ -406,11 +408,32 @@ const TvDetails: React.FC<TvDetailsProps> = ({ tv }) => {
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 mt-4 text-center text-white lg:mr-4 lg:mt-0 lg:text-left">
|
||||
<div className="mb-2 space-x-2">
|
||||
<span className="ml-2 lg:ml-0">
|
||||
{data.mediaInfo && data.mediaInfo.status !== MediaStatus.UNKNOWN && (
|
||||
<span className="ml-2 lg:ml-0">
|
||||
<StatusBadge
|
||||
status={data.mediaInfo?.status}
|
||||
inProgress={(data.mediaInfo.downloadStatus ?? []).length > 0}
|
||||
plexUrl={
|
||||
data.mediaInfo?.plexUrl ?? data.mediaInfo?.jellyfinUrl
|
||||
}
|
||||
plexUrl4k={
|
||||
data.mediaInfo?.plexUrl4k ?? data.mediaInfo?.jellyfinUrl4k
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
<StatusBadge
|
||||
status={data.mediaInfo?.status}
|
||||
inProgress={(data.mediaInfo?.downloadStatus ?? []).length > 0}
|
||||
plexUrl={data.mediaInfo?.plexUrl}
|
||||
plexUrl={data.mediaInfo?.plexUrl ?? data.mediaInfo?.jellyfinUrl}
|
||||
plexUrl4k={
|
||||
data.mediaInfo?.plexUrl4k &&
|
||||
(hasPermission(Permission.REQUEST_4K) ||
|
||||
hasPermission(Permission.REQUEST_4K_TV))
|
||||
? data.mediaInfo.plexUrl4k ?? data.mediaInfo?.jellyfinUrl4k
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
{settings.currentSettings.series4kEnabled &&
|
||||
|
||||
Reference in New Issue
Block a user