mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2026-01-11 17:16:50 -05:00
Compare commits
12 Commits
pr-2273
...
fallenbage
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f02755f03d | ||
|
|
f2c771727c | ||
|
|
87b51b809b | ||
|
|
43553cb2d5 | ||
|
|
8bb7d4e380 | ||
|
|
8c4e39d098 | ||
|
|
973e43f1cc | ||
|
|
a93716eb15 | ||
|
|
6000c36c69 | ||
|
|
6c9aaf9777 | ||
|
|
c4d06540a6 | ||
|
|
98a6075cb6 |
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -91,14 +91,6 @@ body:
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Please provide any additional information that may be relevant or helpful.
|
||||
- type: checkboxes
|
||||
id: search-existing
|
||||
attributes:
|
||||
label: Search Existing Issues
|
||||
description: Have you searched existing issues to see if this bug has already been reported?
|
||||
options:
|
||||
- label: Yes, I have searched existing issues.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
8
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
@@ -27,14 +27,6 @@ body:
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Provide any additional information or screenshots that may be relevant or helpful.
|
||||
- type: checkboxes
|
||||
id: search-existing
|
||||
attributes:
|
||||
label: Search Existing Issues
|
||||
description: Have you searched existing issues to see if this feature has already been requested?
|
||||
options:
|
||||
- label: Yes, I have searched existing issues.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
|
||||
22
README.md
22
README.md
@@ -8,7 +8,7 @@
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/seerr"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
|
||||
<a href="https://hub.docker.com/r/seerr/seerr"><img src="https://img.shields.io/docker/pulls/seerr/seerr" alt="Docker pulls"></a>
|
||||
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/svg-badge.svg" alt="Translation status" /></a>
|
||||
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/seerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
||||
<a href="https://github.com/seerr-team/seerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/seerr-team/seerr"></a>
|
||||
|
||||
**Seerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
|
||||
@@ -32,28 +32,10 @@ With more features on the way! Check out our [issue tracker](/../../issues) to s
|
||||
|
||||
## Getting Started
|
||||
|
||||
For instructions on how to install and run **Jellyseerr**, please refer to the official documentation:
|
||||
Check out our documentation for instructions on how to install and run Seerr:
|
||||
|
||||
https://docs.seerr.dev/getting-started/
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Seerr is not officially released yet.**
|
||||
> The project is currently available **only on the `develop` branch** and is intended for **beta testing only**.
|
||||
|
||||
The documentation linked above is for running the **latest Jellyseerr** release.
|
||||
|
||||
> [!WARNING]
|
||||
> If you are migrating from **Overseerr** to **Seerr** for beta testing, **do not follow the Jellyseerr latest setup guide**.
|
||||
|
||||
Instead, follow the dedicated migration guide:
|
||||
https://github.com/seerr-team/seerr/blob/develop/docs/migration-guide.mdx
|
||||
|
||||
> [!DANGER]
|
||||
> **DO NOT run Jellyseerr (latest) using an existing Overseerr database.**
|
||||
> Doing so **will cause database corruption and/or irreversible data loss**.
|
||||
|
||||
For migration assistance, beta testing questions, or troubleshooting, please join our **Discord** and ask for support there.
|
||||
|
||||
## Preview
|
||||
|
||||
<img src="./public/preview.jpg">
|
||||
|
||||
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[];
|
||||
}
|
||||
@@ -216,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;
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { User } from './User';
|
||||
|
||||
@Entity()
|
||||
@Unique(['endpoint', 'user'])
|
||||
export class UserPushSubscription {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@@ -24,15 +24,6 @@ interface PushNotificationPayload {
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
interface WebPushError extends Error {
|
||||
statusCode?: number;
|
||||
status?: number;
|
||||
body?: string | unknown;
|
||||
response?: {
|
||||
body?: string | unknown;
|
||||
};
|
||||
}
|
||||
|
||||
class WebPushAgent
|
||||
extends BaseAgent<NotificationAgentConfig>
|
||||
implements NotificationAgent
|
||||
@@ -197,30 +188,19 @@ class WebPushAgent
|
||||
notificationPayload
|
||||
);
|
||||
} catch (e) {
|
||||
const webPushError = e as WebPushError;
|
||||
const statusCode = webPushError.statusCode || webPushError.status;
|
||||
const errorMessage = webPushError.message || String(e);
|
||||
|
||||
// RFC 8030: 410/404 are permanent failures, others are transient
|
||||
const isPermanentFailure = statusCode === 410 || statusCode === 404;
|
||||
|
||||
logger.error(
|
||||
isPermanentFailure
|
||||
? 'Error sending web push notification; removing invalid subscription'
|
||||
: 'Error sending web push notification (transient error, keeping subscription)',
|
||||
'Error sending web push notification; removing subscription',
|
||||
{
|
||||
label: 'Notifications',
|
||||
recipient: pushSub.user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage,
|
||||
statusCode: statusCode || 'unknown',
|
||||
errorMessage: e.message,
|
||||
}
|
||||
);
|
||||
|
||||
if (isPermanentFailure) {
|
||||
await userPushSubRepository.remove(pushSub);
|
||||
}
|
||||
// Failed to send notification so we need to remove the subscription
|
||||
userPushSubRepository.remove(pushSub);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -34,8 +34,6 @@ interface ProcessOptions {
|
||||
is4k?: boolean;
|
||||
mediaAddedAt?: Date;
|
||||
ratingKey?: string;
|
||||
jellyfinMediaId?: string;
|
||||
imdbId?: string;
|
||||
serviceId?: number;
|
||||
externalServiceId?: number;
|
||||
externalServiceSlug?: string;
|
||||
@@ -97,8 +95,6 @@ class BaseScanner<T> {
|
||||
is4k = false,
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
jellyfinMediaId,
|
||||
imdbId,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
@@ -137,21 +133,6 @@ class BaseScanner<T> {
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
jellyfinMediaId &&
|
||||
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] !==
|
||||
jellyfinMediaId
|
||||
) {
|
||||
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
|
||||
jellyfinMediaId;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (imdbId && !existing.imdbId) {
|
||||
existing.imdbId = imdbId;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
serviceId !== undefined &&
|
||||
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
|
||||
@@ -192,7 +173,6 @@ class BaseScanner<T> {
|
||||
} else {
|
||||
const newMedia = new Media();
|
||||
newMedia.tmdbId = tmdbId;
|
||||
newMedia.imdbId = imdbId;
|
||||
|
||||
newMedia.status =
|
||||
!is4k && !processing
|
||||
@@ -223,13 +203,6 @@ class BaseScanner<T> {
|
||||
newMedia.ratingKey4k =
|
||||
is4k && this.enable4kMovie ? ratingKey : undefined;
|
||||
}
|
||||
|
||||
if (jellyfinMediaId) {
|
||||
newMedia.jellyfinMediaId = !is4k ? jellyfinMediaId : undefined;
|
||||
newMedia.jellyfinMediaId4k =
|
||||
is4k && this.enable4kMovie ? jellyfinMediaId : undefined;
|
||||
}
|
||||
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved new media: ${title}`);
|
||||
}
|
||||
@@ -248,12 +221,11 @@ class BaseScanner<T> {
|
||||
*/
|
||||
protected async processShow(
|
||||
tmdbId: number,
|
||||
tvdbId: number | undefined,
|
||||
tvdbId: number,
|
||||
seasons: ProcessableSeason[],
|
||||
{
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
jellyfinMediaId,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
@@ -285,7 +257,7 @@ class BaseScanner<T> {
|
||||
(es) => es.seasonNumber === season.seasonNumber
|
||||
);
|
||||
|
||||
// We update the rating keys and jellyfinMediaId in the seasons loop because we need episode counts
|
||||
// We update the rating keys in the seasons loop because we need episode counts
|
||||
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
|
||||
media.ratingKey = ratingKey;
|
||||
}
|
||||
@@ -299,23 +271,6 @@ class BaseScanner<T> {
|
||||
media.ratingKey4k = ratingKey;
|
||||
}
|
||||
|
||||
if (
|
||||
media &&
|
||||
season.episodes > 0 &&
|
||||
media.jellyfinMediaId !== jellyfinMediaId
|
||||
) {
|
||||
media.jellyfinMediaId = jellyfinMediaId;
|
||||
}
|
||||
|
||||
if (
|
||||
media &&
|
||||
season.episodes4k > 0 &&
|
||||
this.enable4kShow &&
|
||||
media.jellyfinMediaId4k !== jellyfinMediaId
|
||||
) {
|
||||
media.jellyfinMediaId4k = jellyfinMediaId;
|
||||
}
|
||||
|
||||
if (existingSeason) {
|
||||
// Here we update seasons if they already exist.
|
||||
// If the season is already marked as available, we
|
||||
@@ -536,22 +491,6 @@ class BaseScanner<T> {
|
||||
)
|
||||
? ratingKey
|
||||
: undefined,
|
||||
jellyfinMediaId: newSeasons.some(
|
||||
(sn) =>
|
||||
sn.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
sn.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? jellyfinMediaId
|
||||
: undefined,
|
||||
jellyfinMediaId4k:
|
||||
this.enable4kShow &&
|
||||
newSeasons.some(
|
||||
(sn) =>
|
||||
sn.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
sn.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? jellyfinMediaId
|
||||
: undefined,
|
||||
status: isAllStandardSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: newSeasons.some(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUniqueConstraintToPushSubscription1765233385034
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddUniqueConstraintToPushSubscription1765233385034';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId")`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" DROP CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005"`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUniqueConstraintToPushSubscription1765233385034
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddUniqueConstraintToPushSubscription1765233385034';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_6427d07d9a171a3a1ab87480005" ON "user_push_subscription" ("endpoint", "userId")`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "UQ_6427d07d9a171a3a1ab87480005"`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -626,6 +809,76 @@ authRoutes.post('/local', async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
const mainUser = await userRepository.findOneOrFail({
|
||||
select: { id: true, plexToken: true, plexId: true },
|
||||
where: { id: 1 },
|
||||
});
|
||||
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
|
||||
|
||||
if (!user.plexId) {
|
||||
try {
|
||||
const plexUsersResponse = await mainPlexTv.getUsers();
|
||||
const account = plexUsersResponse.MediaContainer.User.find(
|
||||
(account) =>
|
||||
account.$.email &&
|
||||
account.$.email.toLowerCase() === user.email.toLowerCase()
|
||||
)?.$;
|
||||
|
||||
if (
|
||||
account &&
|
||||
(await mainPlexTv.checkUserAccess(parseInt(account.id)))
|
||||
) {
|
||||
logger.info(
|
||||
'Found matching Plex user; updating user with Plex data',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
email: body.email,
|
||||
userId: user.id,
|
||||
plexId: account.id,
|
||||
plexUsername: account.username,
|
||||
}
|
||||
);
|
||||
|
||||
user.plexId = parseInt(account.id);
|
||||
user.avatar = account.thumb;
|
||||
user.email = account.email;
|
||||
user.plexUsername = account.username;
|
||||
user.userType = UserType.PLEX;
|
||||
|
||||
await userRepository.save(user);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong fetching Plex users', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
user.plexId &&
|
||||
user.plexId !== mainUser.plexId &&
|
||||
!(await mainPlexTv.checkUserAccess(user.plexId))
|
||||
) {
|
||||
logger.warn(
|
||||
'Failed sign-in attempt from Plex user without access to the media server',
|
||||
{
|
||||
label: 'API',
|
||||
account: {
|
||||
ip: req.ip,
|
||||
email: body.email,
|
||||
userId: user.id,
|
||||
plexId: user.plexId,
|
||||
},
|
||||
}
|
||||
);
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Access denied.',
|
||||
});
|
||||
}
|
||||
|
||||
// Set logged in session
|
||||
if (user && req.session) {
|
||||
req.session.userId = user.id;
|
||||
@@ -705,7 +958,7 @@ authRoutes.post('/logout', async (req, res, next) => {
|
||||
});
|
||||
return next({ status: 500, message: 'Failed to destroy session.' });
|
||||
}
|
||||
logger.debug('Successfully logged out user', {
|
||||
logger.info('Successfully logged out user', {
|
||||
label: 'Auth',
|
||||
userId,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import TautulliAPI from '@server/api/tautulli';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { UserType } from '@server/constants/user';
|
||||
import dataSource, { getRepository } from '@server/datasource';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import { User } from '@server/entity/User';
|
||||
@@ -25,8 +25,7 @@ import { getHostname } from '@server/utils/getHostname';
|
||||
import { Router } from 'express';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
import { findIndex, sortBy } from 'lodash';
|
||||
import type { EntityManager } from 'typeorm';
|
||||
import { In, Not } from 'typeorm';
|
||||
import { In } from 'typeorm';
|
||||
import userSettingsRoutes from './usersettings';
|
||||
|
||||
const router = Router();
|
||||
@@ -189,82 +188,30 @@ router.post<
|
||||
}
|
||||
>('/registerPushSubscription', async (req, res, next) => {
|
||||
try {
|
||||
// This prevents race conditions where two requests both pass the checks
|
||||
await dataSource.transaction(
|
||||
async (transactionalEntityManager: EntityManager) => {
|
||||
const transactionalRepo =
|
||||
transactionalEntityManager.getRepository(UserPushSubscription);
|
||||
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||
|
||||
// Check for existing subscription by auth or endpoint within transaction
|
||||
const existingSubscription = await transactionalRepo.findOne({
|
||||
relations: { user: true },
|
||||
where: [
|
||||
{ auth: req.body.auth, user: { id: req.user?.id } },
|
||||
{ endpoint: req.body.endpoint, user: { id: req.user?.id } },
|
||||
],
|
||||
});
|
||||
const existingSubs = await userPushSubRepository.find({
|
||||
relations: { user: true },
|
||||
where: { auth: req.body.auth, user: { id: req.user?.id } },
|
||||
});
|
||||
|
||||
if (existingSubscription) {
|
||||
// If endpoint matches but auth is different, update with new keys (iOS refresh case)
|
||||
if (
|
||||
existingSubscription.endpoint === req.body.endpoint &&
|
||||
existingSubscription.auth !== req.body.auth
|
||||
) {
|
||||
existingSubscription.auth = req.body.auth;
|
||||
existingSubscription.p256dh = req.body.p256dh;
|
||||
existingSubscription.userAgent = req.body.userAgent;
|
||||
if (existingSubs.length > 0) {
|
||||
logger.debug(
|
||||
'User push subscription already exists. Skipping registration.',
|
||||
{ label: 'API' }
|
||||
);
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
await transactionalRepo.save(existingSubscription);
|
||||
const userPushSubscription = new UserPushSubscription({
|
||||
auth: req.body.auth,
|
||||
endpoint: req.body.endpoint,
|
||||
p256dh: req.body.p256dh,
|
||||
userAgent: req.body.userAgent,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
'Updated existing push subscription with new keys for same endpoint.',
|
||||
{ label: 'API' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
'Duplicate subscription detected. Skipping registration.',
|
||||
{ label: 'API' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up old subscriptions from the same device (userAgent) for this user
|
||||
// iOS can silently refresh endpoints, leaving stale subscriptions in the database
|
||||
// Only clean up if we're creating a new subscription (not updating an existing one)
|
||||
if (req.body.userAgent) {
|
||||
const staleSubscriptions = await transactionalRepo.find({
|
||||
relations: { user: true },
|
||||
where: {
|
||||
userAgent: req.body.userAgent,
|
||||
user: { id: req.user?.id },
|
||||
// Only remove subscriptions with different endpoints (stale ones)
|
||||
// Keep subscriptions that might be from different browsers/tabs
|
||||
endpoint: Not(req.body.endpoint),
|
||||
},
|
||||
});
|
||||
|
||||
if (staleSubscriptions.length > 0) {
|
||||
await transactionalRepo.remove(staleSubscriptions);
|
||||
logger.debug(
|
||||
`Removed ${staleSubscriptions.length} stale push subscription(s) from same device.`,
|
||||
{ label: 'API' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userPushSubscription = new UserPushSubscription({
|
||||
auth: req.body.auth,
|
||||
endpoint: req.body.endpoint,
|
||||
p256dh: req.body.p256dh,
|
||||
userAgent: req.body.userAgent,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
await transactionalRepo.save(userPushSubscription);
|
||||
}
|
||||
);
|
||||
userPushSubRepository.save(userPushSubscription);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
|
||||
@@ -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;
|
||||
@@ -320,14 +320,12 @@ const SettingsMetadata = () => {
|
||||
|
||||
addToast(intl.formatMessage(messages.metadataSettingsSaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} catch (e) {
|
||||
addToast(
|
||||
intl.formatMessage(messages.failedToSaveMetadataSettings),
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -424,7 +422,6 @@ const SettingsMetadata = () => {
|
||||
),
|
||||
{
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -109,28 +109,15 @@ const UserWebPushSettings = () => {
|
||||
// Deletes/disables corresponding push subscription from database
|
||||
const disablePushNotifications = async (endpoint?: string) => {
|
||||
try {
|
||||
const unsubscribedEndpoint = await unsubscribeToPushNotifications(
|
||||
user?.id,
|
||||
endpoint
|
||||
);
|
||||
await unsubscribeToPushNotifications(user?.id, endpoint);
|
||||
|
||||
// Delete from backend if endpoint is available
|
||||
if (subEndpoint) {
|
||||
await deletePushSubscriptionFromBackend(subEndpoint);
|
||||
}
|
||||
|
||||
localStorage.setItem('pushNotificationsEnabled', 'false');
|
||||
setWebPushEnabled(false);
|
||||
|
||||
// Only delete the current browser's subscription, not all devices
|
||||
const endpointToDelete = unsubscribedEndpoint || subEndpoint || endpoint;
|
||||
if (endpointToDelete) {
|
||||
try {
|
||||
await axios.delete(
|
||||
`/api/v1/user/${user?.id}/pushSubscription/${encodeURIComponent(
|
||||
endpointToDelete
|
||||
)}`
|
||||
);
|
||||
} catch {
|
||||
// Ignore deletion failures - backend cleanup is best effort
|
||||
}
|
||||
}
|
||||
|
||||
addToast(intl.formatMessage(messages.webpushhasbeendisabled), {
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
@@ -170,33 +157,7 @@ const UserWebPushSettings = () => {
|
||||
useEffect(() => {
|
||||
const verifyWebPush = async () => {
|
||||
const enabled = await verifyPushSubscription(user?.id, currentSettings);
|
||||
let isEnabled = enabled;
|
||||
|
||||
if (!enabled && 'serviceWorker' in navigator) {
|
||||
const { subscription } = await getPushSubscription();
|
||||
if (subscription) {
|
||||
isEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEnabled && dataDevices && dataDevices.length > 0) {
|
||||
const currentUserAgent = navigator.userAgent;
|
||||
const hasMatchingDevice = dataDevices.some(
|
||||
(device) => device.userAgent === currentUserAgent
|
||||
);
|
||||
|
||||
if (hasMatchingDevice) {
|
||||
isEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
setWebPushEnabled(isEnabled);
|
||||
if (localStorage.getItem('pushNotificationsEnabled') === null) {
|
||||
localStorage.setItem(
|
||||
'pushNotificationsEnabled',
|
||||
isEnabled ? 'true' : 'false'
|
||||
);
|
||||
}
|
||||
setWebPushEnabled(enabled);
|
||||
};
|
||||
|
||||
if (user?.id) {
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -49,17 +49,13 @@ export const verifyPushSubscription = async (
|
||||
currentSettings.vapidPublic
|
||||
).toString();
|
||||
|
||||
if (currentServerKey !== expectedServerKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endpoint = subscription.endpoint;
|
||||
|
||||
const { data } = await axios.get<UserPushSubscription>(
|
||||
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(endpoint)}`
|
||||
);
|
||||
|
||||
return data.endpoint === endpoint;
|
||||
return expectedServerKey === currentServerKey && data.endpoint === endpoint;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -69,39 +65,20 @@ export const verifyAndResubscribePushSubscription = async (
|
||||
userId: number | undefined,
|
||||
currentSettings: PublicSettingsResponse
|
||||
): Promise<boolean> => {
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { subscription } = await getPushSubscription();
|
||||
const isValid = await verifyPushSubscription(userId, currentSettings);
|
||||
|
||||
if (isValid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (subscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentSettings.enablePushRegistration) {
|
||||
try {
|
||||
const oldEndpoint = await unsubscribeToPushNotifications(userId);
|
||||
// Unsubscribe from the backend to clear the existing push subscription (keys and endpoint)
|
||||
await unsubscribeToPushNotifications(userId);
|
||||
|
||||
// Subscribe again to generate a fresh push subscription with updated keys and endpoint
|
||||
await subscribeToPushNotifications(userId, currentSettings);
|
||||
|
||||
if (oldEndpoint) {
|
||||
try {
|
||||
await axios.delete(
|
||||
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(
|
||||
oldEndpoint
|
||||
)}`
|
||||
);
|
||||
} catch (error) {
|
||||
// Ignore errors when deleting old endpoint (it might not exist)
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new Error(`[SW] Resubscribe failed: ${error.message}`);
|
||||
@@ -159,26 +136,24 @@ export const subscribeToPushNotifications = async (
|
||||
export const unsubscribeToPushNotifications = async (
|
||||
userId: number | undefined,
|
||||
endpoint?: string
|
||||
): Promise<string | null> => {
|
||||
) => {
|
||||
if (!('serviceWorker' in navigator) || !userId) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { subscription } = await getPushSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
|
||||
const { endpoint: currentEndpoint } = subscription.toJSON();
|
||||
|
||||
if (!endpoint || endpoint === currentEndpoint) {
|
||||
await subscription.unsubscribe();
|
||||
return currentEndpoint ?? null;
|
||||
return true;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Issue unsubscribing to push notifications: ${error.message}`
|
||||
|
||||
Reference in New Issue
Block a user