mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
Compare commits
15 Commits
renovate/t
...
fallenbage
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f02755f03d | ||
|
|
f2c771727c | ||
|
|
87b51b809b | ||
|
|
43553cb2d5 | ||
|
|
8bb7d4e380 | ||
|
|
8c4e39d098 | ||
|
|
973e43f1cc | ||
|
|
a93716eb15 | ||
|
|
6000c36c69 | ||
|
|
6c9aaf9777 | ||
|
|
c4d06540a6 | ||
|
|
98a6075cb6 | ||
|
|
15356dfe49 | ||
|
|
1f04eeb040 | ||
|
|
e3028c21f2 |
@@ -174,4 +174,36 @@ This can happen if you have a new installation of Jellyfin/Emby or if you have c
|
||||
|
||||
This process should restore your admin privileges while preserving your settings.
|
||||
|
||||
## Failed to enable web push notifications
|
||||
|
||||
### Option 1: You are using Pi-hole
|
||||
|
||||
When using Pi-hole, you need to whitelist the proper domains in order for the queries to not be intercepted and blocked by Pi-hole.
|
||||
If you are using a chromium based browser (eg: Chrome, Brave, Edge...), the domain you need to whitelist is `fcm.googleapis.com`
|
||||
If you are using Firefox, the domain you need to whitelist is `push.services.mozilla.com`
|
||||
|
||||
1. Log into your Pi-hole through the admin interface, then click on Domains situated under GROUP MANAGEMENT.
|
||||
2. Add the domain corresponding to your browser in the `Domain to be added` field and then click on Add to allowed domains.
|
||||
3. Now in order for those changes to be used you need to flush your current dns cache.
|
||||
4. You can do so by using this command line in your Pi-hole terminal:
|
||||
```bash
|
||||
pihole restartdns
|
||||
```
|
||||
If this command fails (which is unlikely), use this equivalent:
|
||||
```bash
|
||||
pihole -f && pihole restartdns
|
||||
```
|
||||
5. Then restart your Seerr instance and try to enable the web push notifications again.
|
||||
|
||||
|
||||
### Option 2: You are using Brave browser
|
||||
|
||||
Brave is a "De-Googled" browser. So by default or if you refused a prompt in the past, it cuts the access to the FCM (Firebase Cloud Messaging) service, which is mandatory for the web push notifications on Chromium based browsers.
|
||||
|
||||
1. Open Brave and paste this address in the url bar: `brave://settings/privacy`
|
||||
2. Look for the option: "Use Google services for push messaging"
|
||||
3. Activate this option
|
||||
4. Relaunch Brave completely
|
||||
5. You should now see the notifications prompt appearing instead of an error message.
|
||||
|
||||
If you still encounter issues, please reach out on our support channels.
|
||||
|
||||
111
seerr-api.yml
111
seerr-api.yml
@@ -3984,6 +3984,85 @@ paths:
|
||||
required:
|
||||
- username
|
||||
- password
|
||||
/auth/jellyfin/quickconnect/initiate:
|
||||
post:
|
||||
summary: Initiate Jellyfin Quick Connect
|
||||
description: Initiates a Quick Connect session and returns a code for the user to authorize on their Jellyfin server.
|
||||
security: []
|
||||
tags:
|
||||
- auth
|
||||
responses:
|
||||
'200':
|
||||
description: Quick Connect session initiated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
example: '123456'
|
||||
secret:
|
||||
type: string
|
||||
example: 'abc123def456'
|
||||
'500':
|
||||
description: Failed to initiate Quick Connect
|
||||
/auth/jellyfin/quickconnect/check:
|
||||
get:
|
||||
summary: Check Quick Connect authorization status
|
||||
description: Checks if the Quick Connect code has been authorized by the user.
|
||||
security: []
|
||||
tags:
|
||||
- auth
|
||||
parameters:
|
||||
- in: query
|
||||
name: secret
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The secret returned from the initiate endpoint
|
||||
responses:
|
||||
'200':
|
||||
description: Authorization status returned
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
authenticated:
|
||||
type: boolean
|
||||
example: false
|
||||
'404':
|
||||
description: Quick Connect session not found or expired
|
||||
/auth/jellyfin/quickconnect/authenticate:
|
||||
post:
|
||||
summary: Authenticate with Quick Connect
|
||||
description: Completes the Quick Connect authentication flow and creates a user session.
|
||||
security: []
|
||||
tags:
|
||||
- auth
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
secret:
|
||||
type: string
|
||||
required:
|
||||
- secret
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully authenticated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/User'
|
||||
'403':
|
||||
description: Quick Connect not authorized or access denied
|
||||
'500':
|
||||
description: Authentication failed
|
||||
/auth/local:
|
||||
post:
|
||||
summary: Sign in using a local account
|
||||
@@ -4913,6 +4992,38 @@ paths:
|
||||
description: Unlink request invalid
|
||||
'404':
|
||||
description: User does not exist
|
||||
/user/{userId}/settings/linked-accounts/jellyfin/quickconnect:
|
||||
post:
|
||||
summary: Link Jellyfin/Emby account with Quick Connect
|
||||
description: Links a Jellyfin/Emby account to the user's profile using Quick Connect authentication
|
||||
tags:
|
||||
- users
|
||||
parameters:
|
||||
- in: path
|
||||
name: userId
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
secret:
|
||||
type: string
|
||||
required:
|
||||
- secret
|
||||
responses:
|
||||
'204':
|
||||
description: Account successfully linked
|
||||
'401':
|
||||
description: Unauthorized
|
||||
'422':
|
||||
description: Account already linked
|
||||
'500':
|
||||
description: Server error
|
||||
/user/{userId}/settings/notifications:
|
||||
get:
|
||||
summary: Get notification settings for a user
|
||||
|
||||
@@ -44,6 +44,23 @@ export interface JellyfinLoginResponse {
|
||||
AccessToken: string;
|
||||
}
|
||||
|
||||
export interface QuickConnectInitiateResponse {
|
||||
Secret: string;
|
||||
Code: string;
|
||||
DateAdded: string;
|
||||
}
|
||||
|
||||
export interface QuickConnectStatusResponse {
|
||||
Authenticated: boolean;
|
||||
Secret: string;
|
||||
Code: string;
|
||||
DeviceId: string;
|
||||
DeviceName: string;
|
||||
AppName: string;
|
||||
AppVersion: string;
|
||||
DateAdded: string;
|
||||
}
|
||||
|
||||
export interface JellyfinUserListResponse {
|
||||
users: JellyfinUserResponse[];
|
||||
}
|
||||
@@ -112,6 +129,10 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
|
||||
DateCreated?: string;
|
||||
}
|
||||
|
||||
type EpisodeReturn<T> = T extends { includeMediaInfo: true }
|
||||
? JellyfinLibraryItemExtended[]
|
||||
: JellyfinLibraryItem[];
|
||||
|
||||
export interface JellyfinItemsReponse {
|
||||
Items: JellyfinLibraryItemExtended[];
|
||||
TotalRecordCount: number;
|
||||
@@ -212,6 +233,62 @@ class JellyfinAPI extends ExternalAPI {
|
||||
}
|
||||
}
|
||||
|
||||
public async initiateQuickConnect(): Promise<QuickConnectInitiateResponse> {
|
||||
try {
|
||||
const response = await this.post<QuickConnectInitiateResponse>(
|
||||
'/QuickConnect/Initiate'
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while initiating Quick Connect: ${e.message}`,
|
||||
{ label: 'Jellyfin API', error: e.response?.status }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
public async checkQuickConnect(
|
||||
secret: string
|
||||
): Promise<QuickConnectStatusResponse> {
|
||||
try {
|
||||
const response = await this.get<QuickConnectStatusResponse>(
|
||||
'/QuickConnect/Connect',
|
||||
{ params: { secret } }
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while getting Quick Connect status: ${e.message}`,
|
||||
{ label: 'Jellyfin API', error: e.response?.status }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
public async authenticateQuickConnect(
|
||||
secret: string
|
||||
): Promise<JellyfinLoginResponse> {
|
||||
try {
|
||||
const response = await this.post<JellyfinLoginResponse>(
|
||||
'/Users/AuthenticateWithQuickConnect',
|
||||
{ Secret: secret }
|
||||
);
|
||||
return response;
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Something went wrong while authenticating with Quick Connect: ${e.message}`,
|
||||
{ label: 'Jellyfin API', error: e.response?.status }
|
||||
);
|
||||
|
||||
throw new ApiError(e.response?.status, ApiErrorCode.Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
public setUserId(userId: string): void {
|
||||
this.userId = userId;
|
||||
return;
|
||||
@@ -415,13 +492,22 @@ class JellyfinAPI extends ExternalAPI {
|
||||
}
|
||||
}
|
||||
|
||||
public async getEpisodes(
|
||||
public async getEpisodes<
|
||||
T extends { includeMediaInfo?: boolean } | undefined = undefined
|
||||
>(
|
||||
seriesID: string,
|
||||
seasonID: string
|
||||
): Promise<JellyfinLibraryItem[]> {
|
||||
seasonID: string,
|
||||
options?: T
|
||||
): Promise<EpisodeReturn<T>> {
|
||||
try {
|
||||
const episodeResponse = await this.get<any>(
|
||||
`/Shows/${seriesID}/Episodes?seasonId=${seasonID}`
|
||||
`/Shows/${seriesID}/Episodes`,
|
||||
{
|
||||
params: {
|
||||
seasonId: seasonID,
|
||||
...(options?.includeMediaInfo && { fields: 'MediaSources' }),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return episodeResponse.Items.filter(
|
||||
|
||||
@@ -374,9 +374,10 @@ class JellyfinScanner {
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
const jellyfinSeasons = await this.jfClient.getSeasons(Id);
|
||||
|
||||
for (const season of seasons) {
|
||||
const JellyfinSeasons = await this.jfClient.getSeasons(Id);
|
||||
const matchedJellyfinSeason = JellyfinSeasons.find((md) => {
|
||||
const matchedJellyfinSeason = jellyfinSeasons.find((md) => {
|
||||
if (tvdbSeasonFromAnidb) {
|
||||
// In AniDB we don't have the concept of seasons,
|
||||
// we have multiple shows with only Season 1 (and sometimes a season with index 0 for specials).
|
||||
@@ -397,38 +398,52 @@ class JellyfinScanner {
|
||||
|
||||
// Check if we found the matching season and it has all the available episodes
|
||||
if (matchedJellyfinSeason) {
|
||||
// If we have a matched Jellyfin season, get its children metadata so we can check details
|
||||
const episodes = await this.jfClient.getEpisodes(
|
||||
Id,
|
||||
matchedJellyfinSeason.Id
|
||||
);
|
||||
|
||||
//Get count of episodes that are HD and 4K
|
||||
let totalStandard = 0;
|
||||
let total4k = 0;
|
||||
|
||||
//use for loop to make sure this loop _completes_ in full
|
||||
//before the next section
|
||||
for (const episode of episodes) {
|
||||
let episodeCount = 1;
|
||||
if (!this.enable4kShow) {
|
||||
const episodes = await this.jfClient.getEpisodes(
|
||||
Id,
|
||||
matchedJellyfinSeason.Id
|
||||
);
|
||||
|
||||
// count number of combined episodes
|
||||
if (
|
||||
episode.IndexNumber !== undefined &&
|
||||
episode.IndexNumberEnd !== undefined
|
||||
) {
|
||||
episodeCount =
|
||||
episode.IndexNumberEnd - episode.IndexNumber + 1;
|
||||
}
|
||||
for (const episode of episodes) {
|
||||
let episodeCount = 1;
|
||||
|
||||
// count number of combined episodes
|
||||
if (
|
||||
episode.IndexNumber !== undefined &&
|
||||
episode.IndexNumberEnd !== undefined
|
||||
) {
|
||||
episodeCount =
|
||||
episode.IndexNumberEnd - episode.IndexNumber + 1;
|
||||
}
|
||||
|
||||
if (!this.enable4kShow) {
|
||||
totalStandard += episodeCount;
|
||||
} else {
|
||||
const ExtendedEpisodeData = await this.jfClient.getItemData(
|
||||
episode.Id
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 4K detection enabled - request media info to check resolution
|
||||
const episodes = await this.jfClient.getEpisodes(
|
||||
Id,
|
||||
matchedJellyfinSeason.Id,
|
||||
{ includeMediaInfo: true }
|
||||
);
|
||||
|
||||
ExtendedEpisodeData?.MediaSources?.some((MediaSource) => {
|
||||
for (const episode of episodes) {
|
||||
let episodeCount = 1;
|
||||
|
||||
// count number of combined episodes
|
||||
if (
|
||||
episode.IndexNumber !== undefined &&
|
||||
episode.IndexNumberEnd !== undefined
|
||||
) {
|
||||
episodeCount =
|
||||
episode.IndexNumberEnd - episode.IndexNumber + 1;
|
||||
}
|
||||
|
||||
// MediaSources field is included in response when includeMediaInfo is true
|
||||
// We iterate all MediaSources to detect if episode has both standard AND 4K versions
|
||||
episode.MediaSources?.some((MediaSource) => {
|
||||
return MediaSource.MediaStreams.some((MediaStream) => {
|
||||
if (MediaStream.Type === 'Video') {
|
||||
if ((MediaStream.Width ?? 0) >= 2000) {
|
||||
|
||||
@@ -594,6 +594,189 @@ authRoutes.post('/jellyfin', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.post('/jellyfin/quickconnect/initiate', async (req, res, next) => {
|
||||
try {
|
||||
const hostname = getHostname();
|
||||
const jellyfinServer = new JellyfinAPI(
|
||||
hostname ?? '',
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const response = await jellyfinServer.initiateQuickConnect();
|
||||
|
||||
return res.status(200).json({
|
||||
code: response.Code,
|
||||
secret: response.Secret,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error initiating Jellyfin quick connect', {
|
||||
label: 'Auth',
|
||||
errorMessage: error.message,
|
||||
});
|
||||
return next({
|
||||
status: 500,
|
||||
message: 'Failed to initiate quick connect.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.get('/jellyfin/quickconnect/check', async (req, res, next) => {
|
||||
const secret = req.query.secret as string;
|
||||
|
||||
if (
|
||||
!secret ||
|
||||
typeof secret !== 'string' ||
|
||||
secret.length < 8 ||
|
||||
secret.length > 128 ||
|
||||
!/^[A-Fa-f0-9]+$/.test(secret)
|
||||
) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Invalid secret format',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const hostname = getHostname();
|
||||
const jellyfinServer = new JellyfinAPI(
|
||||
hostname ?? '',
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const response = await jellyfinServer.checkQuickConnect(secret);
|
||||
|
||||
return res.status(200).json({ authenticated: response.Authenticated });
|
||||
} catch (e) {
|
||||
return next({
|
||||
status: e.statusCode || 500,
|
||||
message: 'Failed to check Quick Connect status',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
authRoutes.post(
|
||||
'/jellyfin/quickconnect/authenticate',
|
||||
async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
const body = req.body as { secret?: string };
|
||||
|
||||
if (
|
||||
!body.secret ||
|
||||
typeof body.secret !== 'string' ||
|
||||
body.secret.length < 8 ||
|
||||
body.secret.length > 128 ||
|
||||
!/^[A-Fa-f0-9]+$/.test(body.secret)
|
||||
) {
|
||||
return next({
|
||||
status: 400,
|
||||
message: 'Secret required',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
settings.main.mediaServerType === MediaServerType.NOT_CONFIGURED ||
|
||||
!(await userRepository.count())
|
||||
) {
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Quick Connect is not available during initial setup.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const hostname = getHostname();
|
||||
const jellyfinServer = new JellyfinAPI(
|
||||
hostname ?? '',
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
const account = await jellyfinServer.authenticateQuickConnect(
|
||||
body.secret
|
||||
);
|
||||
|
||||
let user = await userRepository.findOne({
|
||||
where: { jellyfinUserId: account.User.Id },
|
||||
});
|
||||
|
||||
const deviceId = Buffer.from(`BOT_seerr_qc_${account.User.Id}`).toString(
|
||||
'base64'
|
||||
);
|
||||
|
||||
if (user) {
|
||||
logger.info('Quick Connect sign-in from existing user', {
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
jellyfinUsername: account.User.Name,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
user.jellyfinAuthToken = account.AccessToken;
|
||||
user.jellyfinDeviceId = deviceId;
|
||||
user.avatar = getUserAvatarUrl(user);
|
||||
await userRepository.save(user);
|
||||
} else if (!settings.main.newPlexLogin) {
|
||||
logger.warn(
|
||||
'Failed Quick Connect sign-in attempt by unimported Jellyfin user',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
jellyfinUserId: account.User.Id,
|
||||
jellyfinUsername: account.User.Name,
|
||||
}
|
||||
);
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Access denied.',
|
||||
});
|
||||
} else {
|
||||
logger.info(
|
||||
'Quick Connect sign-in from new Jellyfin user; creating new Seerr user',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
jellyfinUsername: account.User.Name,
|
||||
}
|
||||
);
|
||||
|
||||
user = new User({
|
||||
email: account.User.Name,
|
||||
jellyfinUsername: account.User.Name,
|
||||
jellyfinUserId: account.User.Id,
|
||||
jellyfinDeviceId: deviceId,
|
||||
permissions: settings.main.defaultPermissions,
|
||||
userType:
|
||||
settings.main.mediaServerType === MediaServerType.JELLYFIN
|
||||
? UserType.JELLYFIN
|
||||
: UserType.EMBY,
|
||||
});
|
||||
user.avatar = getUserAvatarUrl(user);
|
||||
await userRepository.save(user);
|
||||
}
|
||||
|
||||
// Set session
|
||||
if (req.session) {
|
||||
req.session.userId = user.id;
|
||||
}
|
||||
|
||||
return res.status(200).json(user?.filter() ?? {});
|
||||
} catch (e) {
|
||||
logger.error('Quick Connect authentication failed', {
|
||||
label: 'Auth',
|
||||
error: e.message,
|
||||
ip: req.ip,
|
||||
});
|
||||
return next({
|
||||
status: e.statusCode || 500,
|
||||
message: ApiErrorCode.InvalidCredentials,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
authRoutes.post('/local', async (req, res, next) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
@@ -543,6 +543,81 @@ userSettingsRoutes.delete<{ id: string }>(
|
||||
}
|
||||
);
|
||||
|
||||
userSettingsRoutes.post<{ secret: string }>(
|
||||
'/linked-accounts/jellyfin/quickconnect',
|
||||
isOwnProfile(),
|
||||
async (req, res) => {
|
||||
const settings = getSettings();
|
||||
const userRepository = getRepository(User);
|
||||
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ code: ApiErrorCode.Unauthorized });
|
||||
}
|
||||
|
||||
const secret = req.body.secret;
|
||||
if (
|
||||
!secret ||
|
||||
typeof secret !== 'string' ||
|
||||
secret.length < 8 ||
|
||||
secret.length > 128 ||
|
||||
!/^[A-Fa-f0-9]+$/.test(secret)
|
||||
) {
|
||||
return res.status(400).json({ message: 'Invalid secret format' });
|
||||
}
|
||||
|
||||
if (
|
||||
settings.main.mediaServerType !== MediaServerType.JELLYFIN &&
|
||||
settings.main.mediaServerType !== MediaServerType.EMBY
|
||||
) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: 'Jellyfin/Emby login is disabled' });
|
||||
}
|
||||
|
||||
const hostname = getHostname();
|
||||
const jellyfinServer = new JellyfinAPI(hostname);
|
||||
|
||||
try {
|
||||
const account = await jellyfinServer.authenticateQuickConnect(secret);
|
||||
|
||||
if (
|
||||
await userRepository.exist({
|
||||
where: { jellyfinUserId: account.User.Id },
|
||||
})
|
||||
) {
|
||||
return res.status(422).json({
|
||||
message: 'The specified account is already linked to a Seerr user',
|
||||
});
|
||||
}
|
||||
|
||||
const user = req.user;
|
||||
const deviceId = Buffer.from(
|
||||
`BOT_seerr_qc_link_${account.User.Id}`
|
||||
).toString('base64');
|
||||
|
||||
user.userType =
|
||||
settings.main.mediaServerType === MediaServerType.EMBY
|
||||
? UserType.EMBY
|
||||
: UserType.JELLYFIN;
|
||||
user.jellyfinUserId = account.User.Id;
|
||||
user.jellyfinUsername = account.User.Name;
|
||||
user.jellyfinAuthToken = account.AccessToken;
|
||||
user.jellyfinDeviceId = deviceId;
|
||||
await userRepository.save(user);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
logger.error('Failed to link account with Quick Connect.', {
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
error: e,
|
||||
});
|
||||
|
||||
return res.status(500).send();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
|
||||
'/notifications',
|
||||
isOwnProfileOrAdmin(),
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import Button from '@app/components/Common/Button';
|
||||
import SensitiveInput from '@app/components/Common/SensitiveInput';
|
||||
import JellyfinQuickConnectModal from '@app/components/Login/JellyfinQuickConnectModal';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
ArrowLeftOnRectangleIcon,
|
||||
QrCodeIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { ApiErrorCode } from '@server/constants/error';
|
||||
import { MediaServerType, ServerType } from '@server/constants/server';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useToasts } from 'react-toast-notifications';
|
||||
import * as Yup from 'yup';
|
||||
@@ -25,6 +30,8 @@ const messages = defineMessages('components.Login', {
|
||||
signingin: 'Signing In…',
|
||||
signin: 'Sign In',
|
||||
forgotpassword: 'Forgot Password?',
|
||||
quickconnect: 'Quick Connect',
|
||||
quickconnecterror: 'Quick Connect failed. Please try again.',
|
||||
});
|
||||
|
||||
interface JellyfinLoginProps {
|
||||
@@ -32,13 +39,11 @@ interface JellyfinLoginProps {
|
||||
serverType?: MediaServerType;
|
||||
}
|
||||
|
||||
const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
revalidate,
|
||||
serverType,
|
||||
}) => {
|
||||
const JellyfinLogin = ({ revalidate, serverType }: JellyfinLoginProps) => {
|
||||
const toasts = useToasts();
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const [showQuickConnect, setShowQuickConnect] = useState(false);
|
||||
|
||||
const mediaServerFormatValues = {
|
||||
mediaServerName:
|
||||
@@ -49,6 +54,16 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
: 'Media Server',
|
||||
};
|
||||
|
||||
const handleQuickConnectError = useCallback(
|
||||
(error: string) => {
|
||||
toasts.addToast(error, {
|
||||
autoDismiss: true,
|
||||
appearance: 'error',
|
||||
});
|
||||
},
|
||||
[toasts]
|
||||
);
|
||||
|
||||
const LoginSchema = Yup.object().shape({
|
||||
username: Yup.string().required(
|
||||
intl.formatMessage(messages.validationusernamerequired)
|
||||
@@ -194,6 +209,30 @@ const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
type="button"
|
||||
onClick={() => setShowQuickConnect(true)}
|
||||
className="w-full"
|
||||
>
|
||||
<QrCodeIcon />
|
||||
<span>{intl.formatMessage(messages.quickconnect)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showQuickConnect && (
|
||||
<JellyfinQuickConnectModal
|
||||
onClose={() => setShowQuickConnect(false)}
|
||||
onAuthenticated={() => {
|
||||
setShowQuickConnect(false);
|
||||
revalidate();
|
||||
}}
|
||||
onError={handleQuickConnectError}
|
||||
mediaServerName={mediaServerFormatValues.mediaServerName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
154
src/components/Login/JellyfinQuickConnectModal.tsx
Normal file
154
src/components/Login/JellyfinQuickConnectModal.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import { useQuickConnect } from '@app/hooks/useQuickConnect';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import axios from 'axios';
|
||||
import { useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('components.Login.JellyfinQuickConnectModal', {
|
||||
title: 'Quick Connect',
|
||||
subtitle: 'Sign in with Quick Connect',
|
||||
instructions: 'Enter this code in your {mediaServerName} app',
|
||||
waitingForAuth: 'Waiting for authorization...',
|
||||
expired: 'Code Expired',
|
||||
expiredMessage: 'This Quick Connect code has expired. Please try again.',
|
||||
error: 'Error',
|
||||
cancel: 'Cancel',
|
||||
tryAgain: 'Try Again',
|
||||
});
|
||||
|
||||
interface JellyfinQuickConnectModalProps {
|
||||
onClose: () => void;
|
||||
onAuthenticated: () => void;
|
||||
onError: (error: string) => void;
|
||||
mediaServerName: string;
|
||||
}
|
||||
|
||||
const JellyfinQuickConnectModal = ({
|
||||
onClose,
|
||||
onAuthenticated,
|
||||
onError,
|
||||
mediaServerName,
|
||||
}: JellyfinQuickConnectModalProps) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const authenticate = useCallback(
|
||||
async (secret: string) => {
|
||||
await axios.post('/api/v1/auth/jellyfin/quickconnect/authenticate', {
|
||||
secret,
|
||||
});
|
||||
onAuthenticated();
|
||||
onClose();
|
||||
},
|
||||
[onAuthenticated, onClose]
|
||||
);
|
||||
|
||||
const {
|
||||
code,
|
||||
isLoading,
|
||||
hasError,
|
||||
isExpired,
|
||||
errorMessage,
|
||||
initiateQuickConnect,
|
||||
cleanup,
|
||||
} = useQuickConnect({
|
||||
show: true,
|
||||
onSuccess: () => {
|
||||
onAuthenticated();
|
||||
onClose();
|
||||
},
|
||||
onError,
|
||||
authenticate,
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
cleanup();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition
|
||||
as="div"
|
||||
appear
|
||||
show
|
||||
enter="transition-opacity ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-in-out duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Modal
|
||||
onCancel={handleClose}
|
||||
title={intl.formatMessage(messages.title)}
|
||||
subTitle={intl.formatMessage(messages.subtitle)}
|
||||
cancelText={intl.formatMessage(messages.cancel)}
|
||||
{...(hasError || isExpired
|
||||
? {
|
||||
okText: intl.formatMessage(messages.tryAgain),
|
||||
onOk: initiateQuickConnect,
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !hasError && !isExpired && (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<p className="text-center text-gray-300">
|
||||
{intl.formatMessage(messages.instructions, {
|
||||
mediaServerName,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="rounded-lg bg-gray-700 px-8 py-4">
|
||||
<span className="text-4xl font-bold tracking-wider text-white">
|
||||
{code}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-400">
|
||||
<div className="h-4 w-4">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<span>{intl.formatMessage(messages.waitingForAuth)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasError && (
|
||||
<div className="flex flex-col items-center space-y-4 py-4">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-red-500">
|
||||
{intl.formatMessage(messages.error)}
|
||||
</h3>
|
||||
<p className="mt-2 text-gray-300">{errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpired && (
|
||||
<div className="flex flex-col items-center space-y-4 py-4">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-yellow-500">
|
||||
{intl.formatMessage(messages.expired)}
|
||||
</h3>
|
||||
<p className="mt-2 text-gray-300">
|
||||
{intl.formatMessage(messages.expiredMessage)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
export default JellyfinQuickConnectModal;
|
||||
@@ -1,9 +1,11 @@
|
||||
import Alert from '@app/components/Common/Alert';
|
||||
import Button from '@app/components/Common/Button';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { QrCodeIcon } from '@heroicons/react/24/outline';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import axios from 'axios';
|
||||
import { Field, Form, Formik } from 'formik';
|
||||
@@ -27,6 +29,7 @@ const messages = defineMessages(
|
||||
'Unable to connect to {mediaServerName} using your credentials',
|
||||
errorExists: 'This account is already linked to a {applicationName} user',
|
||||
errorUnknown: 'An unknown error occurred',
|
||||
quickConnect: 'Use Quick Connect',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -34,13 +37,15 @@ interface LinkJellyfinModalProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onSwitchToQuickConnect: () => void;
|
||||
}
|
||||
|
||||
const LinkJellyfinModal: React.FC<LinkJellyfinModalProps> = ({
|
||||
const LinkJellyfinModal = ({
|
||||
show,
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
onSwitchToQuickConnect,
|
||||
}: LinkJellyfinModalProps) => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { user } = useUser();
|
||||
@@ -167,6 +172,20 @@ const LinkJellyfinModal: React.FC<LinkJellyfinModalProps> = ({
|
||||
<div className="error">{errors.password}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
onSwitchToQuickConnect();
|
||||
}}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<QrCodeIcon />
|
||||
<span>{intl.formatMessage(messages.quickConnect)}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import Alert from '@app/components/Common/Alert';
|
||||
import LoadingSpinner from '@app/components/Common/LoadingSpinner';
|
||||
import Modal from '@app/components/Common/Modal';
|
||||
import { useQuickConnect } from '@app/hooks/useQuickConnect';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { useUser } from '@app/hooks/useUser';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import { Transition } from '@headlessui/react';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import axios from 'axios';
|
||||
import { useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages(
|
||||
'components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal',
|
||||
{
|
||||
title: 'Link {mediaServerName} Account',
|
||||
subtitle: 'Quick Connect',
|
||||
instructions: 'Enter this code in your {mediaServerName} app',
|
||||
waitingForAuth: 'Waiting for authorization...',
|
||||
expired: 'Code Expired',
|
||||
expiredMessage: 'This Quick Connect code has expired. Please try again.',
|
||||
error: 'Error',
|
||||
usePassword: 'Use Password Instead',
|
||||
tryAgain: 'Try Again',
|
||||
errorExists: 'This account is already linked',
|
||||
}
|
||||
);
|
||||
|
||||
interface LinkJellyfinQuickConnectModalProps {
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onSwitchToPassword: () => void;
|
||||
}
|
||||
|
||||
const LinkJellyfinQuickConnectModal = ({
|
||||
show,
|
||||
onClose,
|
||||
onSave,
|
||||
onSwitchToPassword,
|
||||
}: LinkJellyfinQuickConnectModalProps) => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const { user } = useUser();
|
||||
|
||||
const mediaServerName =
|
||||
settings.currentSettings.mediaServerType === MediaServerType.JELLYFIN
|
||||
? 'Jellyfin'
|
||||
: 'Emby';
|
||||
|
||||
const authenticate = useCallback(
|
||||
async (secret: string) => {
|
||||
await axios.post(
|
||||
`/api/v1/user/${user?.id}/settings/linked-accounts/jellyfin/quickconnect`,
|
||||
{ secret }
|
||||
);
|
||||
onSave();
|
||||
onClose();
|
||||
},
|
||||
[user, onSave, onClose]
|
||||
);
|
||||
|
||||
const {
|
||||
code,
|
||||
isLoading,
|
||||
hasError,
|
||||
isExpired,
|
||||
errorMessage,
|
||||
initiateQuickConnect,
|
||||
cleanup,
|
||||
} = useQuickConnect({
|
||||
show: true,
|
||||
onSuccess: () => {
|
||||
onSave();
|
||||
onClose();
|
||||
},
|
||||
authenticate,
|
||||
});
|
||||
|
||||
const handleSwitchToPassword = () => {
|
||||
cleanup();
|
||||
onClose();
|
||||
onSwitchToPassword();
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition
|
||||
as="div"
|
||||
appear
|
||||
show={show}
|
||||
enter="transition-opacity ease-in-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="transition-opacity ease-in-out duration-300"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Modal
|
||||
onCancel={handleSwitchToPassword}
|
||||
title={intl.formatMessage(messages.title, { mediaServerName })}
|
||||
subTitle={intl.formatMessage(messages.subtitle)}
|
||||
cancelText={intl.formatMessage(messages.usePassword)}
|
||||
{...(hasError || isExpired
|
||||
? {
|
||||
okText: intl.formatMessage(messages.tryAgain),
|
||||
onOk: initiateQuickConnect,
|
||||
}
|
||||
: {})}
|
||||
dialogClass="sm:max-w-lg"
|
||||
>
|
||||
{errorMessage && (
|
||||
<div className="mb-4">
|
||||
<Alert type="error">{errorMessage}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !hasError && !isExpired && (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<p className="text-center text-gray-300">
|
||||
{intl.formatMessage(messages.instructions, { mediaServerName })}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="rounded-lg bg-gray-700 px-8 py-4">
|
||||
<span className="text-4xl font-bold tracking-wider text-white">
|
||||
{code}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-400">
|
||||
<div className="h-4 w-4">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
<span>{intl.formatMessage(messages.waitingForAuth)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasError && (
|
||||
<div className="flex flex-col items-center space-y-4 py-4">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-red-500">
|
||||
{intl.formatMessage(messages.error)}
|
||||
</h3>
|
||||
<p className="mt-2 text-gray-300">{errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isExpired && (
|
||||
<div className="flex flex-col items-center space-y-4 py-4">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-yellow-500">
|
||||
{intl.formatMessage(messages.expired)}
|
||||
</h3>
|
||||
<p className="mt-2 text-gray-300">
|
||||
{intl.formatMessage(messages.expiredMessage)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
|
||||
export default LinkJellyfinQuickConnectModal;
|
||||
@@ -5,6 +5,7 @@ import Alert from '@app/components/Common/Alert';
|
||||
import ConfirmButton from '@app/components/Common/ConfirmButton';
|
||||
import Dropdown from '@app/components/Common/Dropdown';
|
||||
import PageTitle from '@app/components/Common/PageTitle';
|
||||
import LinkJellyfinQuickConnectModal from '@app/components/UserProfile/UserSettings/UserLinkedAccountsSettings/LinkJellyfinQuickConnectModal';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { Permission, UserType, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
@@ -63,6 +64,8 @@ const UserLinkedAccountsSettings = () => {
|
||||
user ? `/api/v1/user/${user?.id}/settings/password` : null
|
||||
);
|
||||
const [showJellyfinModal, setShowJellyfinModal] = useState(false);
|
||||
const [showJellyfinQuickConnectModal, setShowJellyfinQuickConnectModal] =
|
||||
useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const applicationName = settings.currentSettings.applicationTitle;
|
||||
@@ -263,6 +266,23 @@ const UserLinkedAccountsSettings = () => {
|
||||
setShowJellyfinModal(false);
|
||||
revalidateUser();
|
||||
}}
|
||||
onSwitchToQuickConnect={() => {
|
||||
setShowJellyfinModal(false);
|
||||
setShowJellyfinQuickConnectModal(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<LinkJellyfinQuickConnectModal
|
||||
show={showJellyfinQuickConnectModal}
|
||||
onClose={() => setShowJellyfinQuickConnectModal(false)}
|
||||
onSave={() => {
|
||||
setShowJellyfinQuickConnectModal(false);
|
||||
revalidateUser();
|
||||
}}
|
||||
onSwitchToPassword={() => {
|
||||
setShowJellyfinQuickConnectModal(false);
|
||||
setShowJellyfinModal(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
183
src/hooks/useQuickConnect.ts
Normal file
183
src/hooks/useQuickConnect.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
import axios from 'axios';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
const messages = defineMessages('hooks.useQuickConnect', {
|
||||
errorMessage: 'Failed to initiate Quick Connect. Please try again.',
|
||||
});
|
||||
|
||||
interface UseQuickConnectOptions {
|
||||
show: boolean;
|
||||
onSuccess: () => void;
|
||||
onError?: (error: string) => void;
|
||||
authenticate: (secret: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useQuickConnect = ({
|
||||
show,
|
||||
onSuccess,
|
||||
onError,
|
||||
authenticate,
|
||||
}: UseQuickConnectOptions) => {
|
||||
const intl = useIntl();
|
||||
const [code, setCode] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [isExpired, setIsExpired] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
const pollingInterval = useRef<NodeJS.Timeout>();
|
||||
const isMounted = useRef(true);
|
||||
const hasInitiated = useRef(false);
|
||||
const errorCount = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
const currentPollingInterval = pollingInterval.current;
|
||||
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
if (currentPollingInterval) {
|
||||
clearInterval(currentPollingInterval);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) {
|
||||
hasInitiated.current = false;
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
const authenticateWithQuickConnect = useCallback(
|
||||
async (secret: string) => {
|
||||
try {
|
||||
await authenticate(secret);
|
||||
if (!isMounted.current) return;
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
const errMsg =
|
||||
error?.response?.data?.message ||
|
||||
intl.formatMessage(messages.errorMessage);
|
||||
setErrorMessage(errMsg);
|
||||
setHasError(true);
|
||||
onError?.(errMsg);
|
||||
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
}
|
||||
},
|
||||
[authenticate, intl, onError, onSuccess]
|
||||
);
|
||||
|
||||
const startPolling = useCallback(
|
||||
(secret: string) => {
|
||||
pollingInterval.current = setInterval(async () => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
'/api/v1/auth/jellyfin/quickconnect/check',
|
||||
{
|
||||
params: { secret },
|
||||
}
|
||||
);
|
||||
|
||||
errorCount.current = 0;
|
||||
|
||||
if (!isMounted.current) {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.data.authenticated) {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
await authenticateWithQuickConnect(secret);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
if (error?.response?.status === 404) {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
setIsExpired(true);
|
||||
} else {
|
||||
errorCount.current++;
|
||||
if (errorCount.current >= 5) {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
setHasError(true);
|
||||
const errorMessage = intl.formatMessage(messages.errorMessage);
|
||||
setErrorMessage(errorMessage);
|
||||
onError?.(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
[authenticateWithQuickConnect, intl, onError]
|
||||
);
|
||||
|
||||
const initiateQuickConnect = useCallback(async () => {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setHasError(false);
|
||||
setIsExpired(false);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
'/api/v1/auth/jellyfin/quickconnect/initiate'
|
||||
);
|
||||
|
||||
if (!isMounted.current) return;
|
||||
|
||||
setCode(response.data.code);
|
||||
setIsLoading(false);
|
||||
startPolling(response.data.secret);
|
||||
} catch (error) {
|
||||
if (!isMounted.current) return;
|
||||
|
||||
setHasError(true);
|
||||
setIsLoading(false);
|
||||
const errMessage = intl.formatMessage(messages.errorMessage);
|
||||
setErrorMessage(errMessage);
|
||||
onError?.(errMessage);
|
||||
}
|
||||
}, [startPolling, onError, intl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (show && !hasInitiated.current) {
|
||||
hasInitiated.current = true;
|
||||
initiateQuickConnect();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [show]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (pollingInterval.current) {
|
||||
clearInterval(pollingInterval.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
code,
|
||||
isLoading,
|
||||
hasError,
|
||||
isExpired,
|
||||
errorMessage,
|
||||
initiateQuickConnect,
|
||||
cleanup,
|
||||
};
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { UserType } from '@server/constants/user';
|
||||
import type { PermissionCheckOptions } from '@server/lib/permissions';
|
||||
import { hasPermission, Permission } from '@server/lib/permissions';
|
||||
import type { NotificationAgentKey } from '@server/lib/settings';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { MutatorCallback } from 'swr';
|
||||
import useSWR from 'swr';
|
||||
|
||||
@@ -56,13 +57,21 @@ export const useUser = ({
|
||||
id,
|
||||
initialData,
|
||||
}: { id?: number; initialData?: User } = {}): UserHookResponse => {
|
||||
const router = useRouter();
|
||||
const isAuthPage = /^\/(login|setup|resetpassword(?:\/|$))/.test(
|
||||
router.pathname
|
||||
);
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
mutate: revalidate,
|
||||
} = useSWR<User>(id ? `/api/v1/user/${id}` : `/api/v1/auth/me`, {
|
||||
fallbackData: initialData,
|
||||
refreshInterval: 30000,
|
||||
refreshInterval: !isAuthPage ? 30000 : 0,
|
||||
revalidateOnFocus: !isAuthPage,
|
||||
revalidateOnMount: !isAuthPage,
|
||||
revalidateOnReconnect: !isAuthPage,
|
||||
errorRetryInterval: 30000,
|
||||
shouldRetryOnError: false,
|
||||
});
|
||||
|
||||
@@ -239,6 +239,17 @@
|
||||
"components.Layout.VersionStatus.outofdate": "Out of Date",
|
||||
"components.Layout.VersionStatus.streamdevelop": "Seerr Develop",
|
||||
"components.Layout.VersionStatus.streamstable": "Seerr Stable",
|
||||
"components.Login.JellyfinQuickConnectModal.authorizationFailed": "Quick Connect authorization failed.",
|
||||
"components.Login.JellyfinQuickConnectModal.cancel": "Cancel",
|
||||
"components.Login.JellyfinQuickConnectModal.error": "Error",
|
||||
"components.Login.JellyfinQuickConnectModal.errorMessage": "Failed to initiate Quick Connect. Please try again.",
|
||||
"components.Login.JellyfinQuickConnectModal.expired": "Code Expired",
|
||||
"components.Login.JellyfinQuickConnectModal.expiredMessage": "This Quick Connect code has expired. Please try again.",
|
||||
"components.Login.JellyfinQuickConnectModal.instructions": "Enter this code in your {mediaServerName} app",
|
||||
"components.Login.JellyfinQuickConnectModal.subtitle": "Sign in with Quick Connect",
|
||||
"components.Login.JellyfinQuickConnectModal.title": "Quick Connect",
|
||||
"components.Login.JellyfinQuickConnectModal.tryAgain": "Try Again",
|
||||
"components.Login.JellyfinQuickConnectModal.waitingForAuth": "Waiting for authorization...",
|
||||
"components.Login.adminerror": "You must use an admin account to sign in.",
|
||||
"components.Login.back": "Go back",
|
||||
"components.Login.credentialerror": "The username or password is incorrect.",
|
||||
@@ -257,6 +268,8 @@
|
||||
"components.Login.orsigninwith": "Or sign in with",
|
||||
"components.Login.password": "Password",
|
||||
"components.Login.port": "Port",
|
||||
"components.Login.quickconnect": "Quick Connect",
|
||||
"components.Login.quickconnecterror": "Quick Connect failed. Please try again.",
|
||||
"components.Login.save": "Add",
|
||||
"components.Login.saving": "Adding…",
|
||||
"components.Login.servertype": "Server Type",
|
||||
@@ -1383,11 +1396,23 @@
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.errorUnknown": "An unknown error occurred",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.password": "Password",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.passwordRequired": "You must provide a password",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.quickConnect": "Use Quick Connect",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.save": "Link",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.saving": "Adding…",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.title": "Link {mediaServerName} Account",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.username": "Username",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinModal.usernameRequired": "You must provide a username",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.error": "Error",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.errorExists": "This account is already linked",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.errorMessage": "Failed to initiate Quick Connect. Please try again.",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.expired": "Code Expired",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.expiredMessage": "This Quick Connect code has expired. Please try again.",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.instructions": "Enter this code in your {mediaServerName} app",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.subtitle": "Quick Connect",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.title": "Link {mediaServerName} Account",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.tryAgain": "Try Again",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.usePassword": "Use Password Instead",
|
||||
"components.UserProfile.UserSettings.LinkJellyfinQuickConnectModal.waitingForAuth": "Waiting for authorization...",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Account Type",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin",
|
||||
"components.UserProfile.UserSettings.UserGeneralSettings.applanguage": "Display Language",
|
||||
|
||||
Reference in New Issue
Block a user