Compare commits

...

16 Commits

Author SHA1 Message Date
Fallenbagel
ac5e2ba6c1 fix(sonarr): re-monitor episodes when re-requesting deleted but monitored seasons
When Sonarr's "Unmonitor Deleted Episodes" is enabled, deleted files cause episodes to be
unmonitored while the season stays monitored. Re-requesting sets season.monitored = true, but Sonarr
only cascades to episodes on state change. Since the season is already monitored, episodes stay
unmonitored and searches find nothing. Now explicitly re-monitors episodes for requested seasons
before triggering a search.

fix #2309
2026-01-19 01:33:38 +05:00
fallenbagel
bb2120c14d fix(base-scanner): fix PROCESSING status persisting for unmonitored seasons (#2311)
BaseScanner's fallthrough logic was preventing unmonitored seasons from
resetting to UNKNOWN status.

fix #2310
2026-01-18 22:32:57 +05:00
fallenbagel
c9037f77e6 fix(network-settings): convert DNS cache TTL values to numbers (#2299)
This PR ensures DNS cache TTL values are properly converted to numbers before being sent to the
backend.

fix #2294
2026-01-17 13:46:05 +01:00
Brandon Cohen
48631db989 fix: preserve deleted status when processing movies (#2066)
* fix: prevent the delete status from changing unless a new request is made"

refactor: remove parent remove change until later date

refactor: remove console log

* fix: add download progress for deleted badge

fix: check if not processing first for movies

* fix: add season pack change
2026-01-17 06:48:14 +05:00
fallenbagel
ac7c2983d3 fix(pushover): prevent notifications when agent is disabled or unconfigured (#2304) 2026-01-16 22:39:15 +01:00
fallenbagel
767dc529e8 fix(ui): correct season pluralization in RequestItem (#2307)
Fixes incorrect "Seasons" label when only one season is requested.

fix #2263
2026-01-16 22:12:21 +01:00
fallenbagel
448a25e2a4 fix(availability-sync): prevent incorrect season deletion when media server is unreachable (#2302) 2026-01-16 10:47:47 +01:00
fallenbagel
3f35b8c886 fix(ui): correct season pluralisation in RequestCard (#2305)
Fixes incorrect "seasons" label when only one season is requested. The plural form was being used
regardless of the actual count

fix #2263
2026-01-16 09:19:13 +01:00
fallenbagel
d0f029b46e fix(login): add missing keys to PlexLoginButton FormattedMessage children (#2291) 2026-01-12 10:50:33 +01:00
fallenbagel
e0a81038cd fix(plex-settings): fix Plex preset selection not enabling save button (#2289)
Uses setValues instead of setFieldValue calls to properly trigger the validation and also added a
validateOnMOunt to ensure form validity is checked on initial render.

fix #2287
2026-01-11 19:17:47 +01:00
fallenbagel
4ab919360a fix(setup): fix Plex login not proceeding after authentication (#2290)
Directly fetch and populate SWR cache with user data instead of relying on revalidate() which is
disabled on auth pages since #2213

fix #2288
2026-01-11 18:43:27 +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
24 changed files with 571 additions and 198 deletions

View File

@@ -32,10 +32,28 @@ With more features on the way! Check out our [issue tracker](/../../issues) to s
## Getting Started ## 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/ 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 ## Preview
<img src="./public/preview.jpg"> <img src="./public/preview.jpg">

View File

@@ -209,6 +209,34 @@ class SonarrAPI extends ServarrBase<{
series: newSeriesResponse.data, 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) { if (options.searchNow) {
this.searchSeries(newSeriesResponse.data.id); this.searchSeries(newSeriesResponse.data.id);
} }
@@ -318,6 +346,38 @@ 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( private buildSeasonList(
seasons: number[], seasons: number[],
existingSeasons?: SonarrSeason[] existingSeasons?: SonarrSeason[]

View File

@@ -1,8 +1,15 @@
import { DbAwareColumn } from '@server/utils/DbColumnHelper'; 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'; import { User } from './User';
@Entity() @Entity()
@Unique(['endpoint', 'user'])
export class UserPushSubscription { export class UserPushSubscription {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
public id: number; public id: number;

View File

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

View File

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

View File

@@ -24,6 +24,15 @@ interface PushNotificationPayload {
isAdmin?: boolean; isAdmin?: boolean;
} }
interface WebPushError extends Error {
statusCode?: number;
status?: number;
body?: string | unknown;
response?: {
body?: string | unknown;
};
}
class WebPushAgent class WebPushAgent
extends BaseAgent<NotificationAgentConfig> extends BaseAgent<NotificationAgentConfig>
implements NotificationAgent implements NotificationAgent
@@ -188,19 +197,30 @@ class WebPushAgent
notificationPayload notificationPayload
); );
} catch (e) { } 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( 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', label: 'Notifications',
recipient: pushSub.user.displayName, recipient: pushSub.user.displayName,
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage,
statusCode: statusCode || 'unknown',
} }
); );
// Failed to send notification so we need to remove the subscription if (isPermanentFailure) {
userPushSubRepository.remove(pushSub); await userPushSubRepository.remove(pushSub);
}
} }
}; };

View File

@@ -115,9 +115,11 @@ class BaseScanner<T> {
let changedExisting = false; let changedExisting = false;
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) { if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
existing[is4k ? 'status4k' : 'status'] = processing existing[is4k ? 'status4k' : 'status'] = !processing
? MediaStatus.PROCESSING ? MediaStatus.AVAILABLE
: MediaStatus.AVAILABLE; : existing[is4k ? 'status4k' : 'status'] === MediaStatus.DELETED
? MediaStatus.DELETED
: MediaStatus.PROCESSING;
if (mediaAddedAt) { if (mediaAddedAt) {
existing.mediaAddedAt = mediaAddedAt; existing.mediaAddedAt = mediaAddedAt;
} }
@@ -330,6 +332,11 @@ class BaseScanner<T> {
season.processing && season.processing &&
existingSeason.status !== MediaStatus.DELETED existingSeason.status !== MediaStatus.DELETED
? MediaStatus.PROCESSING ? MediaStatus.PROCESSING
: !season.is4kOverride &&
!season.processing &&
season.episodes === 0 &&
existingSeason.status === MediaStatus.PROCESSING
? MediaStatus.UNKNOWN
: existingSeason.status; : existingSeason.status;
// Same thing here, except we only do updates if 4k is enabled // Same thing here, except we only do updates if 4k is enabled
@@ -345,6 +352,11 @@ class BaseScanner<T> {
season.processing && season.processing &&
existingSeason.status4k !== MediaStatus.DELETED existingSeason.status4k !== MediaStatus.DELETED
? MediaStatus.PROCESSING ? MediaStatus.PROCESSING
: season.is4kOverride &&
!season.processing &&
season.episodes4k === 0 &&
existingSeason.status4k === MediaStatus.PROCESSING
? MediaStatus.UNKNOWN
: existingSeason.status4k; : existingSeason.status4k;
} else { } else {
newSeasons.push( newSeasons.push(

View File

@@ -13,9 +13,7 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
} }
const userRepository = getRepository(User); const userRepository = getRepository(User);
const users = await userRepository.find({ const users = await userRepository.find();
select: ['id'],
});
let errorOccurred = false; let errorOccurred = false;
@@ -30,15 +28,26 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
}); });
const radarrTags = await radarr.getTags(); const radarrTags = await radarr.getTags();
for (const user of users) { for (const user of users) {
const userTag = radarrTags.find((v) => const userTag = radarrTags.find(
v.label.startsWith(user.id + ' - ') (v) =>
v.label.startsWith(user.id + ' - ') ||
v.label.startsWith(user.id + '-')
); );
if (!userTag) { if (!userTag) {
continue; continue;
} }
await radarr.renameTag({ await radarr.renameTag({
id: userTag.id, 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) { } catch (error) {
@@ -61,15 +70,26 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
}); });
const sonarrTags = await sonarr.getTags(); const sonarrTags = await sonarr.getTags();
for (const user of users) { for (const user of users) {
const userTag = sonarrTags.find((v) => const userTag = sonarrTags.find(
v.label.startsWith(user.id + ' - ') (v) =>
v.label.startsWith(user.id + ' - ') ||
v.label.startsWith(user.id + '-')
); );
if (!userTag) { if (!userTag) {
continue; continue;
} }
await sonarr.renameTag({ await sonarr.renameTag({
id: userTag.id, 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) { } 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

@@ -4,7 +4,7 @@ import TautulliAPI from '@server/api/tautulli';
import { MediaType } from '@server/constants/media'; import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user'; import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource'; import dataSource, { getRepository } from '@server/datasource';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest'; import { MediaRequest } from '@server/entity/MediaRequest';
import { User } from '@server/entity/User'; import { User } from '@server/entity/User';
@@ -25,7 +25,8 @@ import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express'; import { Router } from 'express';
import gravatarUrl from 'gravatar-url'; import gravatarUrl from 'gravatar-url';
import { findIndex, sortBy } from 'lodash'; import { findIndex, sortBy } from 'lodash';
import { In } from 'typeorm'; import type { EntityManager } from 'typeorm';
import { In, Not } from 'typeorm';
import userSettingsRoutes from './usersettings'; import userSettingsRoutes from './usersettings';
const router = Router(); const router = Router();
@@ -188,30 +189,82 @@ router.post<
} }
>('/registerPushSubscription', async (req, res, next) => { >('/registerPushSubscription', async (req, res, next) => {
try { 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({ // Check for existing subscription by auth or endpoint within transaction
relations: { user: true }, const existingSubscription = await transactionalRepo.findOne({
where: { auth: req.body.auth, user: { id: req.user?.id } }, 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) { if (existingSubscription) {
logger.debug( // If endpoint matches but auth is different, update with new keys (iOS refresh case)
'User push subscription already exists. Skipping registration.', if (
{ label: 'API' } existingSubscription.endpoint === req.body.endpoint &&
); existingSubscription.auth !== req.body.auth
return res.status(204).send(); ) {
} existingSubscription.auth = req.body.auth;
existingSubscription.p256dh = req.body.p256dh;
existingSubscription.userAgent = req.body.userAgent;
const userPushSubscription = new UserPushSubscription({ await transactionalRepo.save(existingSubscription);
auth: req.body.auth,
endpoint: req.body.endpoint,
p256dh: req.body.p256dh,
userAgent: req.body.userAgent,
user: req.user,
});
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(); return res.status(204).send();
} catch (e) { } catch (e) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -139,7 +139,11 @@ const StatusBadge = ({
<div <div
className={` className={`
absolute top-0 left-0 z-10 flex h-full bg-opacity-80 ${ absolute top-0 left-0 z-10 flex h-full bg-opacity-80 ${
status === MediaStatus.PROCESSING ? 'bg-indigo-500' : 'bg-green-500' status === MediaStatus.DELETED
? 'bg-red-600'
: status === MediaStatus.PROCESSING
? 'bg-indigo-500'
: 'bg-green-500'
} transition-all duration-200 ease-in-out } transition-all duration-200 ease-in-out
`} `}
style={{ style={{
@@ -373,11 +377,66 @@ const StatusBadge = ({
case MediaStatus.DELETED: case MediaStatus.DELETED:
return ( return (
<Tooltip content={mediaLinkDescription}> <Tooltip
<Badge badgeType="danger"> content={inProgress ? tooltipContent : mediaLinkDescription}
{intl.formatMessage(is4k ? messages.status4k : messages.status, { className={`${
status: intl.formatMessage(globalMessages.deleted), 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>
</Badge> </Badge>
</Tooltip> </Tooltip>
); );

View File

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

View File

@@ -1022,6 +1022,8 @@
"components.Settings.SettingsNetwork.toastSettingsSuccess": "Settings saved successfully!", "components.Settings.SettingsNetwork.toastSettingsSuccess": "Settings saved successfully!",
"components.Settings.SettingsNetwork.trustProxy": "Enable Proxy Support", "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.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.SettingsNetwork.validationProxyPort": "You must provide a valid port",
"components.Settings.SettingsUsers.atLeastOneAuth": "At least one authentication method must be selected.", "components.Settings.SettingsUsers.atLeastOneAuth": "At least one authentication method must be selected.",
"components.Settings.SettingsUsers.defaultPermissions": "Default Permissions", "components.Settings.SettingsUsers.defaultPermissions": "Default Permissions",

View File

@@ -49,13 +49,17 @@ export const verifyPushSubscription = async (
currentSettings.vapidPublic currentSettings.vapidPublic
).toString(); ).toString();
if (currentServerKey !== expectedServerKey) {
return false;
}
const endpoint = subscription.endpoint; const endpoint = subscription.endpoint;
const { data } = await axios.get<UserPushSubscription>( const { data } = await axios.get<UserPushSubscription>(
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(endpoint)}` `/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(endpoint)}`
); );
return expectedServerKey === currentServerKey && data.endpoint === endpoint; return data.endpoint === endpoint;
} catch { } catch {
return false; return false;
} }
@@ -65,20 +69,39 @@ export const verifyAndResubscribePushSubscription = async (
userId: number | undefined, userId: number | undefined,
currentSettings: PublicSettingsResponse currentSettings: PublicSettingsResponse
): Promise<boolean> => { ): Promise<boolean> => {
if (!userId) {
return false;
}
const { subscription } = await getPushSubscription();
const isValid = await verifyPushSubscription(userId, currentSettings); const isValid = await verifyPushSubscription(userId, currentSettings);
if (isValid) { if (isValid) {
return true; return true;
} }
if (subscription) {
return false;
}
if (currentSettings.enablePushRegistration) { if (currentSettings.enablePushRegistration) {
try { try {
// Unsubscribe from the backend to clear the existing push subscription (keys and endpoint) const oldEndpoint = await unsubscribeToPushNotifications(userId);
await unsubscribeToPushNotifications(userId);
// Subscribe again to generate a fresh push subscription with updated keys and endpoint
await subscribeToPushNotifications(userId, currentSettings); 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; return true;
} catch (error) { } catch (error) {
throw new Error(`[SW] Resubscribe failed: ${error.message}`); throw new Error(`[SW] Resubscribe failed: ${error.message}`);
@@ -136,24 +159,26 @@ export const subscribeToPushNotifications = async (
export const unsubscribeToPushNotifications = async ( export const unsubscribeToPushNotifications = async (
userId: number | undefined, userId: number | undefined,
endpoint?: string endpoint?: string
) => { ): Promise<string | null> => {
if (!('serviceWorker' in navigator) || !userId) { if (!('serviceWorker' in navigator) || !userId) {
return; return null;
} }
try { try {
const { subscription } = await getPushSubscription(); const { subscription } = await getPushSubscription();
if (!subscription) { if (!subscription) {
return false; return null;
} }
const { endpoint: currentEndpoint } = subscription.toJSON(); const { endpoint: currentEndpoint } = subscription.toJSON();
if (!endpoint || endpoint === currentEndpoint) { if (!endpoint || endpoint === currentEndpoint) {
await subscription.unsubscribe(); await subscription.unsubscribe();
return true; return currentEndpoint ?? null;
} }
return null;
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Issue unsubscribing to push notifications: ${error.message}` `Issue unsubscribing to push notifications: ${error.message}`