mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2026-01-11 17:16:50 -05:00
Compare commits
4 Commits
pr-2273
...
fallenbage
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fa68da481 | ||
|
|
cf5a85ba0b | ||
|
|
9cbd5f4260 | ||
|
|
09233a32b3 |
20
README.md
20
README.md
@@ -32,28 +32,10 @@ With more features on the way! Check out our [issue tracker](/../../issues) to s
|
||||
|
||||
## Getting Started
|
||||
|
||||
For instructions on how to install and run **Jellyseerr**, please refer to the official documentation:
|
||||
Check out our documentation for instructions on how to install and run Seerr:
|
||||
|
||||
https://docs.seerr.dev/getting-started/
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Seerr is not officially released yet.**
|
||||
> The project is currently available **only on the `develop` branch** and is intended for **beta testing only**.
|
||||
|
||||
The documentation linked above is for running the **latest Jellyseerr** release.
|
||||
|
||||
> [!WARNING]
|
||||
> If you are migrating from **Overseerr** to **Seerr** for beta testing, **do not follow the Jellyseerr latest setup guide**.
|
||||
|
||||
Instead, follow the dedicated migration guide:
|
||||
https://github.com/seerr-team/seerr/blob/develop/docs/migration-guide.mdx
|
||||
|
||||
> [!DANGER]
|
||||
> **DO NOT run Jellyseerr (latest) using an existing Overseerr database.**
|
||||
> Doing so **will cause database corruption and/or irreversible data loss**.
|
||||
|
||||
For migration assistance, beta testing questions, or troubleshooting, please join our **Discord** and ask for support there.
|
||||
|
||||
## Preview
|
||||
|
||||
<img src="./public/preview.jpg">
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { User } from './User';
|
||||
|
||||
@Entity()
|
||||
@Unique(['endpoint', 'user'])
|
||||
export class UserPushSubscription {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@@ -24,15 +24,6 @@ interface PushNotificationPayload {
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
interface WebPushError extends Error {
|
||||
statusCode?: number;
|
||||
status?: number;
|
||||
body?: string | unknown;
|
||||
response?: {
|
||||
body?: string | unknown;
|
||||
};
|
||||
}
|
||||
|
||||
class WebPushAgent
|
||||
extends BaseAgent<NotificationAgentConfig>
|
||||
implements NotificationAgent
|
||||
@@ -197,30 +188,19 @@ class WebPushAgent
|
||||
notificationPayload
|
||||
);
|
||||
} catch (e) {
|
||||
const webPushError = e as WebPushError;
|
||||
const statusCode = webPushError.statusCode || webPushError.status;
|
||||
const errorMessage = webPushError.message || String(e);
|
||||
|
||||
// RFC 8030: 410/404 are permanent failures, others are transient
|
||||
const isPermanentFailure = statusCode === 410 || statusCode === 404;
|
||||
|
||||
logger.error(
|
||||
isPermanentFailure
|
||||
? 'Error sending web push notification; removing invalid subscription'
|
||||
: 'Error sending web push notification (transient error, keeping subscription)',
|
||||
'Error sending web push notification; removing subscription',
|
||||
{
|
||||
label: 'Notifications',
|
||||
recipient: pushSub.user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage,
|
||||
statusCode: statusCode || 'unknown',
|
||||
errorMessage: e.message,
|
||||
}
|
||||
);
|
||||
|
||||
if (isPermanentFailure) {
|
||||
await userPushSubRepository.remove(pushSub);
|
||||
}
|
||||
// Failed to send notification so we need to remove the subscription
|
||||
userPushSubRepository.remove(pushSub);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {
|
||||
StatusBase,
|
||||
} from '@server/lib/scanners/baseScanner';
|
||||
import BaseScanner from '@server/lib/scanners/baseScanner';
|
||||
import serviceAvailabilityChecker from '@server/lib/scanners/serviceAvailabilityChecker';
|
||||
import type { Library } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import { getHostname } from '@server/utils/getHostname';
|
||||
@@ -125,6 +126,57 @@ class JellyfinScanner
|
||||
|
||||
const { tmdbId, imdbId, metadata } = extracted;
|
||||
|
||||
const mediaAddedAt = metadata.DateCreated
|
||||
? new Date(metadata.DateCreated)
|
||||
: undefined;
|
||||
|
||||
if (this.enable4kMovie) {
|
||||
const instanceAvailability =
|
||||
await serviceAvailabilityChecker.checkMovieAvailability(tmdbId);
|
||||
|
||||
if (instanceAvailability.hasStandard || instanceAvailability.has4k) {
|
||||
if (instanceAvailability.hasStandard) {
|
||||
await this.processMovie(tmdbId, {
|
||||
is4k: false,
|
||||
mediaAddedAt,
|
||||
jellyfinMediaId: metadata.Id,
|
||||
imdbId,
|
||||
title: metadata.Name,
|
||||
});
|
||||
}
|
||||
|
||||
if (instanceAvailability.has4k) {
|
||||
await this.processMovie(tmdbId, {
|
||||
is4k: true,
|
||||
mediaAddedAt,
|
||||
jellyfinMediaId: metadata.Id,
|
||||
imdbId,
|
||||
title: metadata.Name,
|
||||
});
|
||||
}
|
||||
|
||||
this.log(
|
||||
`Processed movie with service availability check: ${metadata.Name}`,
|
||||
'debug',
|
||||
{
|
||||
tmdbId,
|
||||
hasStandard: instanceAvailability.hasStandard,
|
||||
has4k: instanceAvailability.has4k,
|
||||
}
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(
|
||||
`Movie not found in any Radarr instance, using resolution-based detection: ${metadata.Name}`,
|
||||
'debug',
|
||||
{
|
||||
tmdbId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const has4k = metadata.MediaSources?.some((MediaSource) => {
|
||||
return MediaSource.MediaStreams.filter(
|
||||
(MediaStream) => MediaStream.Type === 'Video'
|
||||
@@ -141,10 +193,6 @@ class JellyfinScanner
|
||||
});
|
||||
});
|
||||
|
||||
const mediaAddedAt = metadata.DateCreated
|
||||
? new Date(metadata.DateCreated)
|
||||
: undefined;
|
||||
|
||||
if (hasOtherResolution || (!this.enable4kMovie && has4k)) {
|
||||
await this.processMovie(tmdbId, {
|
||||
is4k: false,
|
||||
@@ -285,6 +333,34 @@ class JellyfinScanner
|
||||
? seasons
|
||||
: seasons.filter((sn) => sn.season_number !== 0);
|
||||
|
||||
let instanceAvailability: Awaited<
|
||||
ReturnType<typeof serviceAvailabilityChecker.checkShowAvailability>
|
||||
> | null = null;
|
||||
let useServiceBasedDetection = false;
|
||||
|
||||
if (this.enable4kShow && tvShow.external_ids?.tvdb_id) {
|
||||
instanceAvailability =
|
||||
await serviceAvailabilityChecker.checkShowAvailability(
|
||||
tvShow.external_ids.tvdb_id
|
||||
);
|
||||
|
||||
useServiceBasedDetection =
|
||||
instanceAvailability.hasStandard || instanceAvailability.has4k;
|
||||
|
||||
if (useServiceBasedDetection) {
|
||||
this.log(
|
||||
`Using service availability check for show: ${tvShow.name}`,
|
||||
'debug',
|
||||
{
|
||||
tvdbId: tvShow.external_ids.tvdb_id,
|
||||
hasStandard: instanceAvailability.hasStandard,
|
||||
has4k: instanceAvailability.has4k,
|
||||
seasons: instanceAvailability.seasons.length,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const season of filteredSeasons) {
|
||||
const matchedJellyfinSeason = jellyfinSeasons.find((md) => {
|
||||
if (tvdbSeasonFromAnidb) {
|
||||
@@ -306,7 +382,16 @@ class JellyfinScanner
|
||||
let totalStandard = 0;
|
||||
let total4k = 0;
|
||||
|
||||
if (!this.enable4kShow) {
|
||||
if (useServiceBasedDetection && instanceAvailability) {
|
||||
const serviceSeason = instanceAvailability.seasons.find(
|
||||
(s) => s.seasonNumber === season.season_number
|
||||
);
|
||||
|
||||
if (serviceSeason) {
|
||||
totalStandard = serviceSeason.episodesStandard;
|
||||
total4k = serviceSeason.episodes4k;
|
||||
}
|
||||
} else if (!this.enable4kShow) {
|
||||
const episodes = await this.jfClient.getEpisodes(
|
||||
Id,
|
||||
matchedJellyfinSeason.Id
|
||||
@@ -362,14 +447,6 @@ class JellyfinScanner
|
||||
)
|
||||
);
|
||||
|
||||
// Count in both if episode has both versions
|
||||
// TODO: Make this more robust in the future
|
||||
// Currently, this detection is based solely on file resolution, not which
|
||||
// Radarr/Sonarr instance the file came from. If a 4K request results in
|
||||
// 1080p files (no 4K release available yet), those files will be counted
|
||||
// as "standard" even though they're in the 4K library. This can cause
|
||||
// non-4K users to see content as "available" when they can't access it.
|
||||
// See issue https://github.com/seerr-team/seerr/issues/1744 for details.
|
||||
if (hasStandard) totalStandard += episodeCount;
|
||||
if (has4k) total4k += episodeCount;
|
||||
}
|
||||
@@ -452,6 +529,8 @@ class JellyfinScanner
|
||||
|
||||
const sessionId = this.startRun();
|
||||
|
||||
serviceAvailabilityChecker.clearCache();
|
||||
|
||||
try {
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOne({
|
||||
|
||||
193
server/lib/scanners/serviceAvailabilityChecker.ts
Normal file
193
server/lib/scanners/serviceAvailabilityChecker.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import RadarrAPI from '@server/api/servarr/radarr';
|
||||
import SonarrAPI from '@server/api/servarr/sonarr';
|
||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
|
||||
interface InstanceAvailability {
|
||||
hasStandard: boolean;
|
||||
has4k: boolean;
|
||||
serviceStandardId?: number;
|
||||
service4kId?: number;
|
||||
externalStandardId?: number;
|
||||
external4kId?: number;
|
||||
}
|
||||
|
||||
interface SeasonInstanceAvailability {
|
||||
seasonNumber: number;
|
||||
episodesStandard: number;
|
||||
episodes4k: number;
|
||||
}
|
||||
|
||||
interface ShowInstanceAvailability extends InstanceAvailability {
|
||||
seasons: SeasonInstanceAvailability[];
|
||||
}
|
||||
|
||||
class ServiceAvailabilityChecker {
|
||||
private movieCache: Map<number, InstanceAvailability>;
|
||||
private showCache: Map<number, ShowInstanceAvailability>;
|
||||
|
||||
constructor() {
|
||||
this.movieCache = new Map();
|
||||
this.showCache = new Map();
|
||||
}
|
||||
|
||||
public clearCache(): void {
|
||||
this.movieCache.clear();
|
||||
this.showCache.clear();
|
||||
}
|
||||
|
||||
public async checkMovieAvailability(
|
||||
tmdbid: number
|
||||
): Promise<InstanceAvailability> {
|
||||
const cached = this.movieCache.get(tmdbid);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
const result: InstanceAvailability = {
|
||||
hasStandard: false,
|
||||
has4k: false,
|
||||
};
|
||||
|
||||
if (!settings.radarr || settings.radarr.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const radarrSettings of settings.radarr) {
|
||||
try {
|
||||
const radarr = this.createRadarrClient(radarrSettings);
|
||||
const movie = await radarr.getMovieByTmdbId(tmdbid);
|
||||
|
||||
if (movie?.hasFile) {
|
||||
if (radarrSettings.is4k) {
|
||||
result.has4k = true;
|
||||
result.service4kId = radarrSettings.id;
|
||||
result.external4kId = movie.id;
|
||||
} else {
|
||||
result.hasStandard = true;
|
||||
result.serviceStandardId = radarrSettings.id;
|
||||
result.externalStandardId = movie.id;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Found movie (TMDB: ${tmdbid}) in ${
|
||||
radarrSettings.is4k ? '4K' : 'Standard'
|
||||
} Radarr instance (name: ${radarrSettings.name})`,
|
||||
{
|
||||
label: 'Service Availability',
|
||||
radarrId: radarrSettings.id,
|
||||
movieId: movie?.id,
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
// movie not found in this instance, continue
|
||||
}
|
||||
}
|
||||
|
||||
this.movieCache.set(tmdbid, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async checkShowAvailability(
|
||||
tvdbid: number
|
||||
): Promise<ShowInstanceAvailability> {
|
||||
const cached = this.showCache.get(tvdbid);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
const result: ShowInstanceAvailability = {
|
||||
hasStandard: false,
|
||||
has4k: false,
|
||||
seasons: [],
|
||||
};
|
||||
|
||||
if (!settings.sonarr || settings.sonarr.length === 0) {
|
||||
return result;
|
||||
}
|
||||
const standardSeasons = new Map<number, number>();
|
||||
const seasons4k = new Map<number, number>();
|
||||
|
||||
for (const sonarrSettings of settings.sonarr) {
|
||||
try {
|
||||
const sonarr = this.createSonarrClient(sonarrSettings);
|
||||
const series = await sonarr.getSeriesByTvdbId(tvdbid);
|
||||
|
||||
if (series?.id && series.statistics?.episodeFileCount > 0) {
|
||||
if (sonarrSettings.is4k) {
|
||||
result.has4k = true;
|
||||
result.service4kId = sonarrSettings.id;
|
||||
result.external4kId = series.id;
|
||||
} else {
|
||||
result.hasStandard = true;
|
||||
result.serviceStandardId = sonarrSettings.id;
|
||||
result.externalStandardId = series.id;
|
||||
}
|
||||
|
||||
for (const season of series.seasons) {
|
||||
const episodeCount = season.statistics?.episodeFileCount ?? 0;
|
||||
if (episodeCount > 0) {
|
||||
const targetMap = sonarrSettings.is4k
|
||||
? seasons4k
|
||||
: standardSeasons;
|
||||
const current = targetMap.get(season.seasonNumber) ?? 0;
|
||||
targetMap.set(
|
||||
season.seasonNumber,
|
||||
Math.max(current, episodeCount)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Found series (TVDB: ${tvdbid}) in ${
|
||||
sonarrSettings.is4k ? '4K' : 'Standard'
|
||||
} Sonarr instance (name: ${sonarrSettings.name})`,
|
||||
{
|
||||
label: 'Service Availability',
|
||||
sonarrId: sonarrSettings.id,
|
||||
seriesId: series.id,
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// series not found in this instance, continue
|
||||
}
|
||||
}
|
||||
|
||||
const allSeasonNumbers = new Set([
|
||||
...standardSeasons.keys(),
|
||||
...seasons4k.keys(),
|
||||
]);
|
||||
|
||||
result.seasons = Array.from(allSeasonNumbers).map((seasonNumber) => ({
|
||||
seasonNumber,
|
||||
episodesStandard: standardSeasons.get(seasonNumber) ?? 0,
|
||||
episodes4k: seasons4k.get(seasonNumber) ?? 0,
|
||||
}));
|
||||
|
||||
this.showCache.set(tvdbid, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private createRadarrClient(settings: RadarrSettings): RadarrAPI {
|
||||
return new RadarrAPI({
|
||||
url: RadarrAPI.buildUrl(settings, '/api/v3'),
|
||||
apiKey: settings.apiKey,
|
||||
});
|
||||
}
|
||||
|
||||
private createSonarrClient(settings: SonarrSettings): SonarrAPI {
|
||||
return new SonarrAPI({
|
||||
url: SonarrAPI.buildUrl(settings, '/api/v3'),
|
||||
apiKey: settings.apiKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const serviceAvailabilityChecker = new ServiceAvailabilityChecker();
|
||||
|
||||
export default serviceAvailabilityChecker;
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUniqueConstraintToPushSubscription1765233385034
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddUniqueConstraintToPushSubscription1765233385034';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId")`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" DROP CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005"`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUniqueConstraintToPushSubscription1765233385034
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddUniqueConstraintToPushSubscription1765233385034';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_6427d07d9a171a3a1ab87480005" ON "user_push_subscription" ("endpoint", "userId")`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "UQ_6427d07d9a171a3a1ab87480005"`);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -49,17 +49,13 @@ export const verifyPushSubscription = async (
|
||||
currentSettings.vapidPublic
|
||||
).toString();
|
||||
|
||||
if (currentServerKey !== expectedServerKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endpoint = subscription.endpoint;
|
||||
|
||||
const { data } = await axios.get<UserPushSubscription>(
|
||||
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(endpoint)}`
|
||||
);
|
||||
|
||||
return data.endpoint === endpoint;
|
||||
return expectedServerKey === currentServerKey && data.endpoint === endpoint;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -69,39 +65,20 @@ export const verifyAndResubscribePushSubscription = async (
|
||||
userId: number | undefined,
|
||||
currentSettings: PublicSettingsResponse
|
||||
): Promise<boolean> => {
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { subscription } = await getPushSubscription();
|
||||
const isValid = await verifyPushSubscription(userId, currentSettings);
|
||||
|
||||
if (isValid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (subscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentSettings.enablePushRegistration) {
|
||||
try {
|
||||
const oldEndpoint = await unsubscribeToPushNotifications(userId);
|
||||
// Unsubscribe from the backend to clear the existing push subscription (keys and endpoint)
|
||||
await unsubscribeToPushNotifications(userId);
|
||||
|
||||
// Subscribe again to generate a fresh push subscription with updated keys and endpoint
|
||||
await subscribeToPushNotifications(userId, currentSettings);
|
||||
|
||||
if (oldEndpoint) {
|
||||
try {
|
||||
await axios.delete(
|
||||
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(
|
||||
oldEndpoint
|
||||
)}`
|
||||
);
|
||||
} catch (error) {
|
||||
// Ignore errors when deleting old endpoint (it might not exist)
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new Error(`[SW] Resubscribe failed: ${error.message}`);
|
||||
@@ -159,26 +136,24 @@ export const subscribeToPushNotifications = async (
|
||||
export const unsubscribeToPushNotifications = async (
|
||||
userId: number | undefined,
|
||||
endpoint?: string
|
||||
): Promise<string | null> => {
|
||||
) => {
|
||||
if (!('serviceWorker' in navigator) || !userId) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { subscription } = await getPushSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
|
||||
const { endpoint: currentEndpoint } = subscription.toJSON();
|
||||
|
||||
if (!endpoint || endpoint === currentEndpoint) {
|
||||
await subscription.unsubscribe();
|
||||
return currentEndpoint ?? null;
|
||||
return true;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Issue unsubscribing to push notifications: ${error.message}`
|
||||
|
||||
Reference in New Issue
Block a user