Compare commits

..

5 Commits

Author SHA1 Message Date
0xsysr3ll
3070ba74ae docs(webhook): clarify conflict between Authorization header methods
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-15 19:04:08 +01:00
0xsysr3ll
8979085fb5 feat(webhook): add validation for Authorization header conflict
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-15 19:04:08 +01:00
0xsysr3ll
4232ef19d4 docs(webhook): add a note on the Authorization header precedence
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-15 19:04:08 +01:00
0xsysr3ll
db48f449f5 feat(webhook): add simple validation
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-15 19:04:08 +01:00
0xsysr3ll
37b83fe56d feat(webhook): add support for custom headers in webhook notifications
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2025-12-15 19:04:08 +01:00
33 changed files with 981 additions and 920 deletions

View File

@@ -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:

View File

@@ -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:

View File

@@ -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 (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">

View File

@@ -22,6 +22,17 @@ This is typically not needed. Please refer to your webhook provider's documentat
This value will be sent as an `Authorization` HTTP header.
### Custom Headers (optional)
You can add additional custom HTTP headers to be sent with each webhook request. This is useful for API keys, custom authentication schemes, or any other headers your webhook endpoint requires.
- Click "Add Header" to add a new header
- Enter the header name and value
:::warning
You cannot configure both the **Authorization Header** field and a custom `Authorization` header in Custom Headers at the same time. You must choose one method.
:::
### JSON Payload
Customize the JSON payload to suit your needs. Seerr provides several [template variables](#template-variables) for use in the payload, which will be replaced with the relevant data when the notifications are triggered.

View File

@@ -209,34 +209,6 @@ class SonarrAPI extends ServarrBase<{
series: newSeriesResponse.data,
});
try {
const episodes = await this.getEpisodes(newSeriesResponse.data.id);
const episodeIdsToMonitor = episodes
.filter(
(ep) =>
options.seasons.includes(ep.seasonNumber) && !ep.monitored
)
.map((ep) => ep.id);
if (episodeIdsToMonitor.length > 0) {
logger.debug(
'Re-monitoring unmonitored episodes for requested seasons.',
{
label: 'Sonarr',
seriesId: newSeriesResponse.data.id,
episodeCount: episodeIdsToMonitor.length,
}
);
await this.monitorEpisodes(episodeIdsToMonitor);
}
} catch (e) {
logger.warn('Failed to re-monitor episodes', {
label: 'Sonarr',
errorMessage: e.message,
seriesId: newSeriesResponse.data.id,
});
}
if (options.searchNow) {
this.searchSeries(newSeriesResponse.data.id);
}
@@ -346,38 +318,6 @@ class SonarrAPI extends ServarrBase<{
}
}
public async getEpisodes(seriesId: number): Promise<EpisodeResult[]> {
try {
const response = await this.axios.get<EpisodeResult[]>('/episode', {
params: { seriesId },
});
return response.data;
} catch (e) {
logger.error('Failed to retrieve episodes', {
label: 'Sonarr API',
errorMessage: e.message,
seriesId,
});
throw new Error('Failed to get episodes');
}
}
public async monitorEpisodes(episodeIds: number[]): Promise<void> {
try {
await this.axios.put('/episode/monitor', {
episodeIds,
monitored: true,
});
} catch (e) {
logger.error('Failed to monitor episodes', {
label: 'Sonarr API',
errorMessage: e.message,
episodeIds,
});
throw new Error('Failed to monitor episodes');
}
}
private buildSeasonList(
seasons: number[],
existingSeasons?: SonarrSeason[]

View File

@@ -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;

View File

@@ -300,6 +300,7 @@ class AvailabilitySync {
// Sonarr finds that season, we will change the final seasons value
// to true.
const filteredSeasonsMap: Map<number, boolean> = new Map();
media.seasons
.filter(
(season) =>
@@ -310,7 +311,48 @@ class AvailabilitySync {
filteredSeasonsMap.set(season.seasonNumber, false)
);
// non-4k
const finalSeasons: Map<number, boolean> = new Map();
if (mediaServerType === MediaServerType.PLEX) {
plexSeasonsMap.forEach((value, key) => {
finalSeasons.set(key, value);
});
filteredSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
sonarrSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
jellyfinSeasonsMap.forEach((value, key) => {
finalSeasons.set(key, value);
});
filteredSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
sonarrSeasonsMap.forEach((value, key) => {
if (!finalSeasons.has(key)) {
finalSeasons.set(key, value);
}
});
}
const filteredSeasonsMap4k: Map<number, boolean> = new Map();
media.seasons
.filter(
(season) =>
@@ -321,32 +363,44 @@ class AvailabilitySync {
filteredSeasonsMap4k.set(season.seasonNumber, false)
);
let finalSeasons: Map<number, boolean>;
let finalSeasons4k: Map<number, boolean>;
// 4k
const finalSeasons4k: Map<number, boolean> = new Map();
if (mediaServerType === MediaServerType.PLEX) {
finalSeasons = new Map([
...filteredSeasonsMap,
...plexSeasonsMap,
...sonarrSeasonsMap,
]);
finalSeasons4k = new Map([
...filteredSeasonsMap4k,
...plexSeasonsMap4k,
...sonarrSeasonsMap4k,
]);
} else {
// Jellyfin/Emby
finalSeasons = new Map([
...filteredSeasonsMap,
...jellyfinSeasonsMap,
...sonarrSeasonsMap,
]);
finalSeasons4k = new Map([
...filteredSeasonsMap4k,
...jellyfinSeasonsMap4k,
...sonarrSeasonsMap4k,
]);
plexSeasonsMap4k.forEach((value, key) => {
finalSeasons4k.set(key, value);
});
filteredSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
sonarrSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
} else if (
mediaServerType === MediaServerType.JELLYFIN ||
mediaServerType === MediaServerType.EMBY
) {
jellyfinSeasonsMap4k.forEach((value, key) => {
finalSeasons4k.set(key, value);
});
filteredSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
sonarrSeasonsMap4k.forEach((value, key) => {
if (!finalSeasons4k.has(key)) {
finalSeasons4k.set(key, value);
}
});
}
if (
@@ -939,8 +993,8 @@ class AvailabilitySync {
existsInJellyfin = true;
}
} catch (ex) {
if (!ex.message.includes('404') && !ex.message.includes('500')) {
existsInJellyfin = true;
if (!ex.message.includes('404' || '500')) {
existsInJellyfin = false;
preventSeasonSearch = true;
logger.debug(
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${

View File

@@ -45,17 +45,7 @@ class PushoverAgent
}
public shouldSend(): boolean {
const settings = this.getSettings();
if (
settings.enabled &&
settings.options.accessToken &&
settings.options.userToken
) {
return true;
}
return false;
return true;
}
private async getImagePayload(

View File

@@ -196,16 +196,33 @@ class WebhookAgent
}
try {
const headers: Record<string, string> = {};
if (settings.options.authHeader) {
headers.Authorization = settings.options.authHeader;
}
if (
settings.options.customHeaders &&
settings.options.customHeaders.length > 0
) {
settings.options.customHeaders.forEach((header) => {
if (header.key && header.value) {
// Don't override Authorization header if it's already set via authHeader
if (
header.key.toLowerCase() !== 'authorization' ||
!settings.options.authHeader
) {
headers[header.key] = header.value;
}
}
});
}
await axios.post(
webhookUrl,
this.buildPayload(type, payload),
settings.options.authHeader
? {
headers: {
Authorization: settings.options.authHeader,
},
}
: undefined
Object.keys(headers).length > 0 ? { headers } : undefined
);
return true;

View File

@@ -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);
}
};

View File

@@ -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,
@@ -115,11 +111,9 @@ class BaseScanner<T> {
let changedExisting = false;
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
existing[is4k ? 'status4k' : 'status'] = !processing
? MediaStatus.AVAILABLE
: existing[is4k ? 'status4k' : 'status'] === MediaStatus.DELETED
? MediaStatus.DELETED
: MediaStatus.PROCESSING;
existing[is4k ? 'status4k' : 'status'] = processing
? MediaStatus.PROCESSING
: MediaStatus.AVAILABLE;
if (mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt;
}
@@ -139,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
@@ -194,7 +173,6 @@ class BaseScanner<T> {
} else {
const newMedia = new Media();
newMedia.tmdbId = tmdbId;
newMedia.imdbId = imdbId;
newMedia.status =
!is4k && !processing
@@ -225,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}`);
}
@@ -250,12 +221,11 @@ class BaseScanner<T> {
*/
protected async processShow(
tmdbId: number,
tvdbId: number | undefined,
tvdbId: number,
seasons: ProcessableSeason[],
{
mediaAddedAt,
ratingKey,
jellyfinMediaId,
serviceId,
externalServiceId,
externalServiceSlug,
@@ -287,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;
}
@@ -301,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
@@ -332,11 +285,6 @@ class BaseScanner<T> {
season.processing &&
existingSeason.status !== MediaStatus.DELETED
? MediaStatus.PROCESSING
: !season.is4kOverride &&
!season.processing &&
season.episodes === 0 &&
existingSeason.status === MediaStatus.PROCESSING
? MediaStatus.UNKNOWN
: existingSeason.status;
// Same thing here, except we only do updates if 4k is enabled
@@ -352,11 +300,6 @@ class BaseScanner<T> {
season.processing &&
existingSeason.status4k !== MediaStatus.DELETED
? MediaStatus.PROCESSING
: season.is4kOverride &&
!season.processing &&
season.episodes4k === 0 &&
existingSeason.status4k === MediaStatus.PROCESSING
? MediaStatus.UNKNOWN
: existingSeason.status4k;
} else {
newSeasons.push(
@@ -548,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

View File

@@ -275,6 +275,7 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig {
webhookUrl: string;
jsonPayload: string;
authHeader?: string;
customHeaders?: { key: string; value: string }[];
supportVariables?: boolean;
};
}

View File

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

View File

@@ -1,28 +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(`
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

@@ -1,26 +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(`
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

@@ -279,6 +279,7 @@ notificationRoutes.get('/webhook', (_req, res) => {
'utf8'
)
),
customHeaders: webhookSettings.options.customHeaders ?? [],
supportVariables: webhookSettings.options.supportVariables ?? false,
},
};
@@ -301,6 +302,7 @@ notificationRoutes.post('/webhook', async (req, res, next) => {
),
webhookUrl: req.body.options.webhookUrl,
authHeader: req.body.options.authHeader,
customHeaders: req.body.options.customHeaders ?? [],
supportVariables: req.body.options.supportVariables ?? false,
},
};
@@ -333,6 +335,7 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => {
),
webhookUrl: req.body.options.webhookUrl,
authHeader: req.body.options.authHeader,
customHeaders: req.body.options.customHeaders ?? [],
supportVariables: req.body.options.supportVariables ?? false,
},
};

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 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) {

View File

@@ -29,16 +29,6 @@ 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>
@@ -320,15 +310,11 @@ export class MediaRequestSubscriber
mediaId: entity.media.id,
userId: entity.requestedBy.id,
newTag:
entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
});
userTag = await radarr.createTag({
label:
entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
});
}
if (userTag.id) {
@@ -645,15 +631,11 @@ export class MediaRequestSubscriber
mediaId: entity.media.id,
userId: entity.requestedBy.id,
newTag:
entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
});
userTag = await sonarr.createTag({
label:
entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
});
}
if (userTag.id) {

View File

@@ -3,7 +3,6 @@ import Button from '@app/components/Common/Button';
import { SmallLoadingSpinner } from '@app/components/Common/LoadingSpinner';
import usePlexLogin from '@app/hooks/usePlexLogin';
import defineMessages from '@app/utils/defineMessages';
import { Fragment } from 'react';
import { FormattedMessage } from 'react-intl';
const messages = defineMessages('components.Login', {
@@ -47,12 +46,8 @@ const PlexLoginButton = ({
>
{(chunks) => (
<>
{chunks.map((c, index) =>
typeof c === 'string' ? (
<span key={index}>{c}</span>
) : (
<Fragment key={index}>{c}</Fragment>
)
{chunks.map((c) =>
typeof c === 'string' ? <span>{c}</span> : c
)}
</>
)}

View File

@@ -25,7 +25,6 @@ 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';
@@ -34,17 +33,6 @@ 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',
@@ -242,30 +230,26 @@ const ManageSlideOver = ({
</h3>
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
<ul>
{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>
)
)}
{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>
))}
</ul>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import Tooltip from '@app/components/Common/Tooltip';
import RequestModal from '@app/components/RequestModal';
import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
@@ -218,6 +219,7 @@ interface RequestCardProps {
}
const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
const settings = useSettings();
const { ref, inView } = useInView({
triggerOnce: true,
});
@@ -400,7 +402,14 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
<div className="my-0.5 hidden items-center text-sm sm:my-1 sm:flex">
<span className="mr-2 font-bold ">
{intl.formatMessage(messages.seasons, {
seasonCount: request.seasons.length,
seasonCount:
(settings.currentSettings.enableSpecialEpisodes
? title.seasons.length
: title.seasons.filter(
(season) => season.seasonNumber !== 0
).length) === request.seasons.length
? 0
: request.seasons.length,
})}
</span>
<div className="hide-scrollbar overflow-x-scroll">

View File

@@ -5,6 +5,7 @@ import ConfirmButton from '@app/components/Common/ConfirmButton';
import RequestModal from '@app/components/RequestModal';
import StatusBadge from '@app/components/StatusBadge';
import useDeepLinks from '@app/hooks/useDeepLinks';
import useSettings from '@app/hooks/useSettings';
import { Permission, useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
@@ -294,6 +295,7 @@ interface RequestItemProps {
}
const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
const settings = useSettings();
const { ref, inView } = useInView({
triggerOnce: true,
});
@@ -468,7 +470,14 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
<div className="card-field">
<span className="card-field-name">
{intl.formatMessage(messages.seasons, {
seasonCount: request.seasons.length,
seasonCount:
(settings.currentSettings.enableSpecialEpisodes
? title.seasons.length
: title.seasons.filter(
(season) => season.seasonNumber !== 0
).length) === request.seasons.length
? 0
: request.seasons.length,
})}
</span>
<div className="hide-scrollbar flex flex-nowrap overflow-x-scroll">

View File

@@ -49,12 +49,7 @@ const NotificationsPushover = () => {
const { data: soundsData } = useSWR<PushoverSound[]>(
data?.options.accessToken
? `/api/v1/settings/notifications/pushover/sounds?token=${data.options.accessToken}`
: null,
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
shouldRetryOnError: false,
}
: null
);
const NotificationsPushoverSchema = Yup.object().shape({

View File

@@ -5,7 +5,12 @@ import SettingsBadge from '@app/components/Settings/SettingsBadge';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { isValidURL } from '@app/utils/urlValidationHelper';
import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline';
import {
ArrowDownOnSquareIcon,
BeakerIcon,
PlusIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import {
ArrowPathIcon,
QuestionMarkCircleIcon,
@@ -80,6 +85,16 @@ const messages = defineMessages(
supportVariablesTip:
'Available variables are documented in the webhook template variables section',
authheader: 'Authorization Header',
customHeaders: 'Custom Headers',
customHeadersTip:
'Add custom HTTP headers to include with webhook requests',
customHeadersAdd: 'Add Header',
customHeadersRemove: 'Remove',
customHeadersKey: 'Header Name',
customHeadersValue: 'Header Value',
customHeadersIncomplete: 'All headers must have both name and value',
customHeadersAuthConflict:
'Cannot use both Authorization Header and custom Authorization header. Please remove one.',
validationJsonPayloadRequired: 'You must provide a valid JSON payload',
webhooksettingssaved: 'Webhook notification settings saved successfully!',
webhooksettingsfailed: 'Webhook notification settings failed to save.',
@@ -125,6 +140,43 @@ const NotificationsWebhook = () => {
supportVariables: Yup.boolean(),
customHeaders: Yup.array()
.of(
Yup.object().shape({
key: Yup.string(),
value: Yup.string(),
})
)
.test(
'complete-headers',
intl.formatMessage(messages.customHeadersIncomplete),
function (headers) {
if (!headers || headers.length === 0) return true;
return headers.every(
(header) =>
(!header.key || !header.key.trim()) ===
(!header.value || !header.value.trim())
);
}
)
.test(
'auth-conflict',
intl.formatMessage(messages.customHeadersAuthConflict),
function (headers) {
const { authHeader } = this.parent;
if (!authHeader || !headers || headers.length === 0) return true;
const hasCustomAuthHeader = headers.some(
(header) =>
header.key &&
header.value &&
header.key.toLowerCase() === 'authorization'
);
return !hasCustomAuthHeader;
}
),
jsonPayload: Yup.string()
.when('enabled', {
is: true,
@@ -159,6 +211,7 @@ const NotificationsWebhook = () => {
webhookUrl: data.options.webhookUrl,
jsonPayload: data.options.jsonPayload,
authHeader: data.options.authHeader,
customHeaders: data.options.customHeaders ?? [],
supportVariables: data.options.supportVariables ?? false,
}}
validationSchema={NotificationsWebhookSchema}
@@ -171,6 +224,9 @@ const NotificationsWebhook = () => {
webhookUrl: values.webhookUrl,
jsonPayload: JSON.stringify(values.jsonPayload),
authHeader: values.authHeader,
customHeaders: values.customHeaders.filter(
(h: { key: string; value: string }) => h.key && h.value
),
supportVariables: values.supportVariables,
},
});
@@ -229,6 +285,9 @@ const NotificationsWebhook = () => {
webhookUrl: values.webhookUrl,
jsonPayload: JSON.stringify(values.jsonPayload),
authHeader: values.authHeader,
customHeaders: values.customHeaders.filter(
(h: { key: string; value: string }) => h.key && h.value
),
supportVariables: values.supportVariables ?? false,
},
});
@@ -344,6 +403,86 @@ const NotificationsWebhook = () => {
</div>
</div>
</div>
<div className="form-row">
<label htmlFor="customHeaders" className="text-label">
{intl.formatMessage(messages.customHeaders)}
<span className="label-tip">
{intl.formatMessage(messages.customHeadersTip)}
</span>
</label>
<div className="form-input-area">
<div className="space-y-2">
{values.customHeaders.map(
(header: { key: string; value: string }, index: number) => (
<div key={index} className="flex gap-2">
<div className="flex-1">
<div className="form-input-field">
<Field
name={`customHeaders.${index}.key`}
type="text"
placeholder={intl.formatMessage(
messages.customHeadersKey
)}
/>
</div>
</div>
<div className="flex-1">
<div className="form-input-field">
<Field
name={`customHeaders.${index}.value`}
type="text"
placeholder={intl.formatMessage(
messages.customHeadersValue
)}
/>
</div>
</div>
<div className="flex items-center">
<Button
buttonType="danger"
buttonSize="sm"
onClick={(e) => {
e.preventDefault();
const newHeaders = values.customHeaders.filter(
(
_: { key: string; value: string },
i: number
) => i !== index
);
setFieldValue('customHeaders', newHeaders);
}}
title={intl.formatMessage(
messages.customHeadersRemove
)}
>
<TrashIcon />
</Button>
</div>
</div>
)
)}
<Button
buttonType="default"
buttonSize="sm"
onClick={(e) => {
e.preventDefault();
setFieldValue('customHeaders', [
...values.customHeaders,
{ key: '', value: '' },
]);
}}
>
<PlusIcon />
<span>{intl.formatMessage(messages.customHeadersAdd)}</span>
</Button>
</div>
{errors.customHeaders &&
touched.customHeaders &&
typeof errors.customHeaders === 'string' && (
<div className="error">{errors.customHeaders}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="webhook-json-payload" className="text-label">
{intl.formatMessage(messages.customJson)}

View File

@@ -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,
}
);
}

View File

@@ -38,8 +38,6 @@ const messages = defineMessages('components.Settings.SettingsNetwork', {
proxyBypassFilterTip:
"Use ',' as a separator, and '*.' as a wildcard for subdomains",
proxyBypassLocalAddresses: 'Bypass Proxy for Local Addresses',
validationDnsCacheMinTtl: 'You must provide a valid minimum TTL',
validationDnsCacheMaxTtl: 'You must provide a valid maximum TTL',
validationProxyPort: 'You must provide a valid port',
networkDisclaimer:
'Network parameters from your container/system should be used instead of these settings. See the {docs} for more information.',
@@ -66,20 +64,6 @@ const SettingsNetwork = () => {
} = useSWR<NetworkSettings>('/api/v1/settings/network');
const NetworkSettingsSchema = Yup.object().shape({
dnsCacheForceMinTtl: Yup.number().when('dnsCacheEnabled', {
is: true,
then: Yup.number()
.typeError(intl.formatMessage(messages.validationDnsCacheMinTtl))
.required(intl.formatMessage(messages.validationDnsCacheMinTtl))
.min(0),
}),
dnsCacheForceMaxTtl: Yup.number().when('dnsCacheEnabled', {
is: true,
then: Yup.number()
.typeError(intl.formatMessage(messages.validationDnsCacheMaxTtl))
.required(intl.formatMessage(messages.validationDnsCacheMaxTtl))
.min(0),
}),
proxyPort: Yup.number().when('proxyEnabled', {
is: (proxyEnabled: boolean) => proxyEnabled,
then: Yup.number().required(
@@ -136,8 +120,8 @@ const SettingsNetwork = () => {
trustProxy: values.trustProxy,
dnsCache: {
enabled: values.dnsCacheEnabled,
forceMinTtl: Number(values.dnsCacheForceMinTtl),
forceMaxTtl: Number(values.dnsCacheForceMaxTtl),
forceMinTtl: values.dnsCacheForceMinTtl,
forceMaxTtl: values.dnsCacheForceMaxTtl,
},
proxy: {
enabled: values.proxyEnabled,
@@ -297,7 +281,7 @@ const SettingsNetwork = () => {
<Field
id="dnsCacheForceMinTtl"
name="dnsCacheForceMinTtl"
type="number"
type="text"
/>
</div>
{errors.dnsCacheForceMinTtl &&
@@ -321,7 +305,7 @@ const SettingsNetwork = () => {
<Field
id="dnsCacheForceMaxTtl"
name="dnsCacheForceMaxTtl"
type="number"
type="text"
/>
</div>
{errors.dnsCacheForceMaxTtl &&
@@ -391,7 +375,7 @@ const SettingsNetwork = () => {
<Field
id="proxyPort"
name="proxyPort"
type="number"
type="text"
/>
</div>
{errors.proxyPort &&

View File

@@ -377,7 +377,6 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
webAppUrl: data?.webAppUrl,
}}
validationSchema={PlexSettingsSchema}
validateOnMount={true}
onSubmit={async (values) => {
let toastId: string | null = null;
try {
@@ -424,7 +423,6 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
values,
handleSubmit,
setFieldValue,
setValues,
isSubmitting,
isValid,
}) => {
@@ -447,12 +445,9 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
availablePresets[Number(e.target.value)];
if (targPreset) {
setValues({
...values,
hostname: targPreset.address,
port: targPreset.port,
useSsl: targPreset.ssl,
});
setFieldValue('hostname', targPreset.address);
setFieldValue('port', targPreset.port);
setFieldValue('useSsl', targPreset.ssl);
}
}}
>

View File

@@ -28,8 +28,7 @@ const LoginWithPlex = ({ onComplete }: LoginWithPlexProps) => {
const response = await axios.post('/api/v1/auth/plex', { authToken });
if (response.data?.id) {
const { data: user } = await axios.get('/api/v1/auth/me');
revalidate(user, false);
revalidate();
}
};
if (authToken) {

View File

@@ -139,11 +139,7 @@ const StatusBadge = ({
<div
className={`
absolute top-0 left-0 z-10 flex h-full bg-opacity-80 ${
status === MediaStatus.DELETED
? 'bg-red-600'
: status === MediaStatus.PROCESSING
? 'bg-indigo-500'
: 'bg-green-500'
status === MediaStatus.PROCESSING ? 'bg-indigo-500' : 'bg-green-500'
} transition-all duration-200 ease-in-out
`}
style={{
@@ -377,66 +373,11 @@ const StatusBadge = ({
case MediaStatus.DELETED:
return (
<Tooltip
content={inProgress ? tooltipContent : mediaLinkDescription}
className={`${
inProgress && 'hidden max-h-96 w-96 overflow-y-auto sm:block'
}`}
tooltipConfig={{
...(inProgress && { interactive: true, delayHide: 100 }),
}}
>
<Badge
badgeType="danger"
href={mediaLink}
className={`${
inProgress &&
'relative !bg-gray-700 !bg-opacity-80 !px-0 hover:!bg-gray-700'
} overflow-hidden`}
>
{inProgress && badgeDownloadProgress}
<div
className={`relative z-20 flex items-center ${
inProgress && 'px-2'
}`}
>
<span>
{intl.formatMessage(
is4k ? messages.status4k : messages.status,
{
status: inProgress
? intl.formatMessage(globalMessages.processing)
: intl.formatMessage(globalMessages.deleted),
}
)}
</span>
{inProgress && (
<>
{mediaType === 'tv' &&
downloadItem[0].episode &&
(downloadItem.length > 1 &&
downloadItem.every(
(item) =>
item.downloadId &&
item.downloadId === downloadItem[0].downloadId
) ? (
<span className="ml-1">
{intl.formatMessage(messages.seasonnumber, {
seasonNumber: downloadItem[0].episode.seasonNumber,
})}
</span>
) : (
<span className="ml-1">
{intl.formatMessage(messages.seasonepisodenumber, {
seasonNumber: downloadItem[0].episode.seasonNumber,
episodeNumber: downloadItem[0].episode.episodeNumber,
})}
</span>
))}
<Spinner className="ml-1 h-3 w-3" />
</>
)}
</div>
<Tooltip content={mediaLinkDescription}>
<Badge badgeType="danger">
{intl.formatMessage(is4k ? messages.status4k : messages.status, {
status: intl.formatMessage(globalMessages.deleted),
})}
</Badge>
</Tooltip>
);

View File

@@ -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) {

View File

@@ -681,6 +681,14 @@
"components.Settings.Notifications.NotificationsSlack.webhookUrlTip": "Create an <WebhookLink>Incoming Webhook</WebhookLink> integration",
"components.Settings.Notifications.NotificationsWebhook.agentenabled": "Enable Agent",
"components.Settings.Notifications.NotificationsWebhook.authheader": "Authorization Header",
"components.Settings.Notifications.NotificationsWebhook.customHeaders": "Custom Headers",
"components.Settings.Notifications.NotificationsWebhook.customHeadersAdd": "Add Header",
"components.Settings.Notifications.NotificationsWebhook.customHeadersAuthConflict": "Cannot use both Authorization Header and custom Authorization header. Please remove one.",
"components.Settings.Notifications.NotificationsWebhook.customHeadersIncomplete": "All headers must have both name and value",
"components.Settings.Notifications.NotificationsWebhook.customHeadersKey": "Header Name",
"components.Settings.Notifications.NotificationsWebhook.customHeadersRemove": "Remove",
"components.Settings.Notifications.NotificationsWebhook.customHeadersTip": "Add custom HTTP headers to include with webhook requests",
"components.Settings.Notifications.NotificationsWebhook.customHeadersValue": "Header Value",
"components.Settings.Notifications.NotificationsWebhook.customJson": "JSON Payload",
"components.Settings.Notifications.NotificationsWebhook.resetPayload": "Reset to Default",
"components.Settings.Notifications.NotificationsWebhook.resetPayloadSuccess": "JSON payload reset successfully!",
@@ -1022,8 +1030,6 @@
"components.Settings.SettingsNetwork.toastSettingsSuccess": "Settings saved successfully!",
"components.Settings.SettingsNetwork.trustProxy": "Enable Proxy Support",
"components.Settings.SettingsNetwork.trustProxyTip": "Allow Seerr to correctly register client IP addresses behind a proxy",
"components.Settings.SettingsNetwork.validationDnsCacheMaxTtl": "You must provide a valid maximum TTL",
"components.Settings.SettingsNetwork.validationDnsCacheMinTtl": "You must provide a valid minimum TTL",
"components.Settings.SettingsNetwork.validationProxyPort": "You must provide a valid port",
"components.Settings.SettingsUsers.atLeastOneAuth": "At least one authentication method must be selected.",
"components.Settings.SettingsUsers.defaultPermissions": "Default Permissions",

View File

@@ -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}`