1
0
mirror of https://github.com/fallenbagel/jellyseerr.git synced 2026-01-11 17:16:50 -05:00

Compare commits

..

11 Commits

Author SHA1 Message Date
gauthier-th
9e1e7030a7 feat(ui): rebrand Jellyseerr logos to Seerr 2026-01-07 21:49:12 +01:00
Someone
adbcf80333 fix(ui): remove duplicate download items in manage slide over (#1916)
* fix(ui): filter duplicate downloads in ManageSlideOver using downloadId

Apply the same logic as PR #927 to deduplicate season pack downloads
in the "Manage Series" slide-over panel.

* Update src/components/ManageSlideOver/index.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/components/ManageSlideOver/index.tsx

Co-authored-by: Gauthier <mail@gauthierth.fr>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Gauthier <mail@gauthierth.fr>
2026-01-07 16:06:11 +00:00
fallenbagel
f91a26befe fix(servarr): replace spaces in arr user tags with - (#2231)
* fix: sanitize disallowed characters in arr tags

Updates the tag creation to normalize diacritics, replace spaces with hyphens and stip any
non-alphanumeric characters from display name

fix #2229, fix #1897

* refactor: improve display name sanitization in tag creation

* fix: include displayName in user selection for tag migration

* fix(migrator): retrieve all user fields in tag migration

This is a one time migration so performance is neglible. This should trigger the @AfterLoad hooks
which sets the `displayName`
2026-01-06 03:18:06 +08:00
0xsysr3ll
0c95b5ec91 fix(migration): add cleanup step for duplicate push subscriptions before enforcing unique constraint (#2269)
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2026-01-05 10:54:31 +01:00
fallenbagel
193d4dc668 docs: temporarily make it clear seerr is not released (#2273) 2026-01-03 04:53:18 +00:00
0xsysr3ll
d0c9afc16e fix(webpush): improve iOS push subscription endpoint cleanup (#2140) 2025-12-31 13:44:45 +01:00
fallenbagel
57d583e1bd refactor(jellyfin-scanner): extend BaseScanner for jellyfin scanner (#2226)
* refactor(jellyfin-scanner): extend BaseScanner for jellyfin scanner

Refactors JellyfinScanner to extend BaseScanner class to align the jellyfin scanner architecture
with the plex scanner and reduce code duplication.

* fix(jellyfin-scanner): add imdbId handling back to fix a regression from original behaviour

* fix: add imdbId assignment for existing media entries

* fix: include imdbId in processed 4k media items and improve 4k detection

* fix(jellyfin-scanner): filter seasons based on settings for special episodes (regression)
2025-12-29 20:05:47 +08:00
samohtxotom
8bbe7864af chore(metadata-settings): add autoDismiss to toast notifications (#2254) 2025-12-27 06:27:12 +08:00
Gauthier
66b4e2c871 chore(issuetemplate): add a checkbox to search for existing issues (#2255) 2025-12-27 06:26:16 +08:00
fallenbagel
3ee69663dc fix(local-login): remove automatic plex linking and reduce logout log verbosity (#2225)
Removed redundant Plex user discovery logic that applies to all media servers currently. This is now
handled explicitly via linked accounts settings page. Also changed the successful logout log level
from info to debug since its routine behaviour
2025-12-15 19:44:43 +08:00
Ludovic Ortega
539d49879d chore: fix translate badge svg url (#2228)
* chore: fix translate badge svg url

Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>

* fix: use https instead of http

Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>

---------

Signed-off-by: Ludovic Ortega <ludovic.ortega@adminafk.fr>
2025-12-14 05:37:36 +08:00
80 changed files with 737 additions and 2248 deletions

View File

@@ -91,6 +91,14 @@ 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:

View File

@@ -27,6 +27,14 @@ 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:

View File

@@ -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/seerr-frontend/svg-badge.svg" alt="Translation status" /></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://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,10 +32,28 @@ With more features on the way! Check out our [issue tracker](/../../issues) to s
## Getting Started
Check out our documentation for instructions on how to install and run Seerr:
For instructions on how to install and run **Jellyseerr**, please refer to the official documentation:
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 (with `:develop` tag):
https://github.com/seerr-team/seerr/blob/develop/docs/migration-guide.mdx
> [!CAUTION]
> **DO NOT run Jellyseerr (latest) using an existing Overseerr database. This includes third-party images with `seerr:latest` (as it points to jellyseerr 2.7.3 and not seerr.**
> Doing so **may cause database corruption and/or irreversible data loss and/or weird unintended behaviour**.
For migration assistance, beta testing questions, or troubleshooting, please join our **Discord** and ask for support there.
## Preview
<img src="./public/preview.jpg">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 821 B

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 410 KiB

View File

@@ -3984,85 +3984,6 @@ 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
@@ -4992,38 +4913,6 @@ 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

View File

@@ -44,23 +44,6 @@ 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[];
}
@@ -233,62 +216,6 @@ 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;

View File

@@ -1,8 +1,15 @@
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import {
Column,
Entity,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
import { User } from './User';
@Entity()
@Unique(['endpoint', 'user'])
export class UserPushSubscription {
@PrimaryGeneratedColumn()
public id: number;

View File

@@ -24,6 +24,15 @@ 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
@@ -188,19 +197,30 @@ 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(
'Error sending web push notification; removing subscription',
isPermanentFailure
? 'Error sending web push notification; removing invalid subscription'
: 'Error sending web push notification (transient error, keeping subscription)',
{
label: 'Notifications',
recipient: pushSub.user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
errorMessage,
statusCode: statusCode || 'unknown',
}
);
// Failed to send notification so we need to remove the subscription
userPushSubRepository.remove(pushSub);
if (isPermanentFailure) {
await userPushSubRepository.remove(pushSub);
}
}
};

View File

@@ -34,6 +34,8 @@ interface ProcessOptions {
is4k?: boolean;
mediaAddedAt?: Date;
ratingKey?: string;
jellyfinMediaId?: string;
imdbId?: string;
serviceId?: number;
externalServiceId?: number;
externalServiceSlug?: string;
@@ -95,6 +97,8 @@ class BaseScanner<T> {
is4k = false,
mediaAddedAt,
ratingKey,
jellyfinMediaId,
imdbId,
serviceId,
externalServiceId,
externalServiceSlug,
@@ -133,6 +137,21 @@ 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
@@ -173,6 +192,7 @@ class BaseScanner<T> {
} else {
const newMedia = new Media();
newMedia.tmdbId = tmdbId;
newMedia.imdbId = imdbId;
newMedia.status =
!is4k && !processing
@@ -203,6 +223,13 @@ 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}`);
}
@@ -221,11 +248,12 @@ class BaseScanner<T> {
*/
protected async processShow(
tmdbId: number,
tvdbId: number,
tvdbId: number | undefined,
seasons: ProcessableSeason[],
{
mediaAddedAt,
ratingKey,
jellyfinMediaId,
serviceId,
externalServiceId,
externalServiceSlug,
@@ -257,7 +285,7 @@ class BaseScanner<T> {
(es) => es.seasonNumber === season.seasonNumber
);
// We update the rating keys in the seasons loop because we need episode counts
// We update the rating keys and jellyfinMediaId in the seasons loop because we need episode counts
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
media.ratingKey = ratingKey;
}
@@ -271,6 +299,23 @@ 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
@@ -491,6 +536,22 @@ 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

View File

@@ -13,9 +13,7 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
}
const userRepository = getRepository(User);
const users = await userRepository.find({
select: ['id'],
});
const users = await userRepository.find();
let errorOccurred = false;
@@ -30,15 +28,26 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
});
const radarrTags = await radarr.getTags();
for (const user of users) {
const userTag = radarrTags.find((v) =>
v.label.startsWith(user.id + ' - ')
const userTag = radarrTags.find(
(v) =>
v.label.startsWith(user.id + ' - ') ||
v.label.startsWith(user.id + '-')
);
if (!userTag) {
continue;
}
await radarr.renameTag({
id: userTag.id,
label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
label:
user.id +
'-' +
user.displayName
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/gi, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, ''),
});
}
} catch (error) {
@@ -61,15 +70,26 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
});
const sonarrTags = await sonarr.getTags();
for (const user of users) {
const userTag = sonarrTags.find((v) =>
v.label.startsWith(user.id + ' - ')
const userTag = sonarrTags.find(
(v) =>
v.label.startsWith(user.id + ' - ') ||
v.label.startsWith(user.id + '-')
);
if (!userTag) {
continue;
}
await sonarr.renameTag({
id: userTag.id,
label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
label:
user.id +
'-' +
user.displayName
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/gi, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, ''),
});
}
} catch (error) {

View File

@@ -0,0 +1,28 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUniqueConstraintToPushSubscription1765233385034
implements MigrationInterface
{
name = 'AddUniqueConstraintToPushSubscription1765233385034';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM "user_push_subscription"
WHERE id NOT IN (
SELECT MAX(id)
FROM "user_push_subscription"
GROUP BY "endpoint", "userId"
)
`);
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"`
);
}
}

View File

@@ -0,0 +1,26 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUniqueConstraintToPushSubscription1765233385034
implements MigrationInterface
{
name = 'AddUniqueConstraintToPushSubscription1765233385034';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM "user_push_subscription"
WHERE id NOT IN (
SELECT MAX(id)
FROM "user_push_subscription"
GROUP BY "endpoint", "userId"
)
`);
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"`);
}
}

View File

@@ -594,189 +594,6 @@ 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);
@@ -809,76 +626,6 @@ 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;
@@ -958,7 +705,7 @@ authRoutes.post('/logout', async (req, res, next) => {
});
return next({ status: 500, message: 'Failed to destroy session.' });
}
logger.info('Successfully logged out user', {
logger.debug('Successfully logged out user', {
label: 'Auth',
userId,
});

View File

@@ -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 { getRepository } from '@server/datasource';
import dataSource, { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
import { User } from '@server/entity/User';
@@ -25,7 +25,8 @@ import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import { findIndex, sortBy } from 'lodash';
import { In } from 'typeorm';
import type { EntityManager } from 'typeorm';
import { In, Not } from 'typeorm';
import userSettingsRoutes from './usersettings';
const router = Router();
@@ -188,30 +189,82 @@ router.post<
}
>('/registerPushSubscription', async (req, res, next) => {
try {
const userPushSubRepository = getRepository(UserPushSubscription);
// This prevents race conditions where two requests both pass the checks
await dataSource.transaction(
async (transactionalEntityManager: EntityManager) => {
const transactionalRepo =
transactionalEntityManager.getRepository(UserPushSubscription);
const existingSubs = await userPushSubRepository.find({
relations: { user: true },
where: { auth: req.body.auth, user: { id: req.user?.id } },
});
// 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 } },
],
});
if (existingSubs.length > 0) {
logger.debug(
'User push subscription already exists. Skipping registration.',
{ label: 'API' }
);
return res.status(204).send();
}
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;
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(existingSubscription);
userPushSubRepository.save(userPushSubscription);
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);
}
);
return res.status(204).send();
} catch (e) {

View File

@@ -543,81 +543,6 @@ 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(),

View File

@@ -29,6 +29,16 @@ import type {
} from 'typeorm';
import { EventSubscriber } from 'typeorm';
const sanitizeDisplayName = (displayName: string): string => {
return displayName
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/gi, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
};
@EventSubscriber()
export class MediaRequestSubscriber
implements EntitySubscriberInterface<MediaRequest>
@@ -310,11 +320,15 @@ export class MediaRequestSubscriber
mediaId: entity.media.id,
userId: entity.requestedBy.id,
newTag:
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
});
userTag = await radarr.createTag({
label:
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
});
}
if (userTag.id) {
@@ -631,11 +645,15 @@ export class MediaRequestSubscriber
mediaId: entity.media.id,
userId: entity.requestedBy.id,
newTag:
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
});
userTag = await sonarr.createTag({
label:
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
});
}
if (userTag.id) {

View File

@@ -1,17 +1,12 @@
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,
QrCodeIcon,
} from '@heroicons/react/24/outline';
import { ArrowLeftOnRectangleIcon } 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';
@@ -30,8 +25,6 @@ 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 {
@@ -39,11 +32,13 @@ interface JellyfinLoginProps {
serverType?: MediaServerType;
}
const JellyfinLogin = ({ revalidate, serverType }: JellyfinLoginProps) => {
const JellyfinLogin: React.FC<JellyfinLoginProps> = ({
revalidate,
serverType,
}) => {
const toasts = useToasts();
const intl = useIntl();
const settings = useSettings();
const [showQuickConnect, setShowQuickConnect] = useState(false);
const mediaServerFormatValues = {
mediaServerName:
@@ -54,16 +49,6 @@ const JellyfinLogin = ({ revalidate, serverType }: 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)
@@ -209,30 +194,6 @@ const JellyfinLogin = ({ revalidate, serverType }: 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>
);
};

View File

@@ -1,154 +0,0 @@
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;

View File

@@ -25,6 +25,7 @@ import {
} from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfaces';
import type { DownloadingItem } from '@server/lib/downloadtracker';
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv';
@@ -33,6 +34,17 @@ import Link from 'next/link';
import { useIntl } from 'react-intl';
import useSWR from 'swr';
const filterDuplicateDownloads = (
items: DownloadingItem[] = []
): DownloadingItem[] => {
const seen = new Set<string>();
return items.filter((item) => {
if (seen.has(item.downloadId)) return false;
seen.add(item.downloadId);
return true;
});
};
const messages = defineMessages('components.ManageSlideOver', {
manageModalTitle: 'Manage {mediaType}',
manageModalIssues: 'Open Issues',
@@ -230,26 +242,30 @@ const ManageSlideOver = ({
</h3>
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
<ul>
{data.mediaInfo?.downloadStatus?.map((status, index) => (
<Tooltip
key={`dl-status-${status.externalId}-${index}`}
content={status.title}
>
<li className="border-b border-gray-700 last:border-b-0">
<DownloadBlock downloadItem={status} />
</li>
</Tooltip>
))}
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
<Tooltip
key={`dl-status-${status.externalId}-${index}`}
content={status.title}
>
<li className="border-b border-gray-700 last:border-b-0">
<DownloadBlock downloadItem={status} is4k />
</li>
</Tooltip>
))}
{filterDuplicateDownloads(data.mediaInfo?.downloadStatus).map(
(status, index) => (
<Tooltip
key={`dl-status-${status.externalId}-${index}`}
content={status.title}
>
<li className="border-b border-gray-700 last:border-b-0">
<DownloadBlock downloadItem={status} />
</li>
</Tooltip>
)
)}
{filterDuplicateDownloads(data.mediaInfo?.downloadStatus4k).map(
(status, index) => (
<Tooltip
key={`dl-status-4k-${status.externalId}-${index}`}
content={status.title}
>
<li className="border-b border-gray-700 last:border-b-0">
<DownloadBlock downloadItem={status} is4k />
</li>
</Tooltip>
)
)}
</ul>
</div>
</div>

View File

@@ -320,12 +320,14 @@ const SettingsMetadata = () => {
addToast(intl.formatMessage(messages.metadataSettingsSaved), {
appearance: 'success',
autoDismiss: true,
});
} catch (e) {
addToast(
intl.formatMessage(messages.failedToSaveMetadataSettings),
{
appearance: 'error',
autoDismiss: true,
}
);
}
@@ -422,6 +424,7 @@ const SettingsMetadata = () => {
),
{
appearance: 'success',
autoDismiss: true,
}
);
}

View File

@@ -1,11 +1,9 @@
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';
@@ -29,7 +27,6 @@ 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',
}
);
@@ -37,15 +34,13 @@ interface LinkJellyfinModalProps {
show: boolean;
onClose: () => void;
onSave: () => void;
onSwitchToQuickConnect: () => void;
}
const LinkJellyfinModal = ({
const LinkJellyfinModal: React.FC<LinkJellyfinModalProps> = ({
show,
onClose,
onSave,
onSwitchToQuickConnect,
}: LinkJellyfinModalProps) => {
}) => {
const intl = useIntl();
const settings = useSettings();
const { user } = useUser();
@@ -172,20 +167,6 @@ const LinkJellyfinModal = ({
<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>
);

View File

@@ -1,175 +0,0 @@
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;

View File

@@ -5,7 +5,6 @@ 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';
@@ -64,8 +63,6 @@ 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;
@@ -266,23 +263,6 @@ 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);
}}
/>
</>
);

View File

@@ -109,15 +109,28 @@ const UserWebPushSettings = () => {
// Deletes/disables corresponding push subscription from database
const disablePushNotifications = async (endpoint?: string) => {
try {
await unsubscribeToPushNotifications(user?.id, endpoint);
// Delete from backend if endpoint is available
if (subEndpoint) {
await deletePushSubscriptionFromBackend(subEndpoint);
}
const unsubscribedEndpoint = await unsubscribeToPushNotifications(
user?.id,
endpoint
);
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',
@@ -157,7 +170,33 @@ const UserWebPushSettings = () => {
useEffect(() => {
const verifyWebPush = async () => {
const enabled = await verifyPushSubscription(user?.id, currentSettings);
setWebPushEnabled(enabled);
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'
);
}
};
if (user?.id) {

View File

@@ -1,183 +0,0 @@
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,
};
};

View File

@@ -239,17 +239,6 @@
"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.",
@@ -268,8 +257,6 @@
"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",
@@ -1396,23 +1383,11 @@
"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",

View File

@@ -49,13 +49,17 @@ 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 expectedServerKey === currentServerKey && data.endpoint === endpoint;
return data.endpoint === endpoint;
} catch {
return false;
}
@@ -65,20 +69,39 @@ 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 {
// Unsubscribe from the backend to clear the existing push subscription (keys and endpoint)
await unsubscribeToPushNotifications(userId);
const oldEndpoint = 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}`);
@@ -136,24 +159,26 @@ export const subscribeToPushNotifications = async (
export const unsubscribeToPushNotifications = async (
userId: number | undefined,
endpoint?: string
) => {
): Promise<string | null> => {
if (!('serviceWorker' in navigator) || !userId) {
return;
return null;
}
try {
const { subscription } = await getPushSubscription();
if (!subscription) {
return false;
return null;
}
const { endpoint: currentEndpoint } = subscription.toJSON();
if (!endpoint || endpoint === currentEndpoint) {
await subscription.unsubscribe();
return true;
return currentEndpoint ?? null;
}
return null;
} catch (error) {
throw new Error(
`Issue unsubscribing to push notifications: ${error.message}`