Compare commits
11 Commits
fallenbage
...
fallenbage
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b92a26bfcb | ||
|
|
0753d7f48e | ||
|
|
6f9988085b | ||
|
|
d0f029b46e | ||
|
|
e0a81038cd | ||
|
|
4ab919360a | ||
|
|
adbcf80333 | ||
|
|
f91a26befe | ||
|
|
0c95b5ec91 | ||
|
|
193d4dc668 | ||
|
|
d0c9afc16e |
20
README.md
20
README.md
@@ -32,10 +32,28 @@ With more features on the way! Check out our [issue tracker](/../../issues) to s
|
||||
|
||||
## Getting Started
|
||||
|
||||
Check out our documentation for instructions on how to install and run Seerr:
|
||||
For instructions on how to install and run **Jellyseerr**, please refer to the official documentation:
|
||||
|
||||
https://docs.seerr.dev/getting-started/
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Seerr is not officially released yet.**
|
||||
> The project is currently available **only on the `develop` branch** and is intended for **beta testing only**.
|
||||
|
||||
The documentation linked above is for running the **latest Jellyseerr** release.
|
||||
|
||||
> [!WARNING]
|
||||
> If you are migrating from **Overseerr** to **Seerr** for beta testing, **do not follow the Jellyseerr latest setup guide**.
|
||||
|
||||
Instead, follow the dedicated migration guide (with `:develop` tag):
|
||||
https://github.com/seerr-team/seerr/blob/develop/docs/migration-guide.mdx
|
||||
|
||||
> [!CAUTION]
|
||||
> **DO NOT run Jellyseerr (latest) using an existing Overseerr database. This includes third-party images with `seerr:latest` (as it points to jellyseerr 2.7.3 and not seerr.**
|
||||
> Doing so **may cause database corruption and/or irreversible data loss and/or weird unintended behaviour**.
|
||||
|
||||
For migration assistance, beta testing questions, or troubleshooting, please join our **Discord** and ask for support there.
|
||||
|
||||
## Preview
|
||||
|
||||
<img src="./public/preview.jpg">
|
||||
|
||||
@@ -24,6 +24,10 @@ Set this to the username and password for your ntfy.sh server.
|
||||
|
||||
Set this to the token for your ntfy.sh server.
|
||||
|
||||
### Priority (optional)
|
||||
|
||||
Set the priority level for notifications. Options range from Minimum (1) to Urgent (5), with Default (3) being the standard level. Higher priority notifications may bypass Do Not Disturb settings on some devices.
|
||||
|
||||
:::info
|
||||
Please refer to the [ntfy.sh API documentation](https://docs.ntfy.sh/) for more details on configuring these notifications.
|
||||
:::
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { User } from './User';
|
||||
|
||||
@Entity()
|
||||
@Unique(['endpoint', 'user'])
|
||||
export class UserPushSubscription {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@@ -27,7 +27,7 @@ class NtfyAgent
|
||||
const { embedPoster } = settings.notifications.agents.ntfy;
|
||||
|
||||
const topic = this.getSettings().options.topic;
|
||||
const priority = 3;
|
||||
const priority = this.getSettings().options.priority ?? 3;
|
||||
|
||||
const title = payload.event
|
||||
? `${payload.event} - ${payload.subject}`
|
||||
|
||||
@@ -24,6 +24,15 @@ interface PushNotificationPayload {
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
interface WebPushError extends Error {
|
||||
statusCode?: number;
|
||||
status?: number;
|
||||
body?: string | unknown;
|
||||
response?: {
|
||||
body?: string | unknown;
|
||||
};
|
||||
}
|
||||
|
||||
class WebPushAgent
|
||||
extends BaseAgent<NotificationAgentConfig>
|
||||
implements NotificationAgent
|
||||
@@ -188,19 +197,30 @@ class WebPushAgent
|
||||
notificationPayload
|
||||
);
|
||||
} catch (e) {
|
||||
const webPushError = e as WebPushError;
|
||||
const statusCode = webPushError.statusCode || webPushError.status;
|
||||
const errorMessage = webPushError.message || String(e);
|
||||
|
||||
// RFC 8030: 410/404 are permanent failures, others are transient
|
||||
const isPermanentFailure = statusCode === 410 || statusCode === 404;
|
||||
|
||||
logger.error(
|
||||
'Error sending web push notification; removing subscription',
|
||||
isPermanentFailure
|
||||
? 'Error sending web push notification; removing invalid subscription'
|
||||
: 'Error sending web push notification (transient error, keeping subscription)',
|
||||
{
|
||||
label: 'Notifications',
|
||||
recipient: pushSub.user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
errorMessage,
|
||||
statusCode: statusCode || 'unknown',
|
||||
}
|
||||
);
|
||||
|
||||
// Failed to send notification so we need to remove the subscription
|
||||
userPushSubRepository.remove(pushSub);
|
||||
if (isPermanentFailure) {
|
||||
await userPushSubRepository.remove(pushSub);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ 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';
|
||||
@@ -126,57 +125,6 @@ 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'
|
||||
@@ -193,6 +141,10 @@ class JellyfinScanner
|
||||
});
|
||||
});
|
||||
|
||||
const mediaAddedAt = metadata.DateCreated
|
||||
? new Date(metadata.DateCreated)
|
||||
: undefined;
|
||||
|
||||
if (hasOtherResolution || (!this.enable4kMovie && has4k)) {
|
||||
await this.processMovie(tmdbId, {
|
||||
is4k: false,
|
||||
@@ -333,34 +285,6 @@ 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) {
|
||||
@@ -382,16 +306,7 @@ class JellyfinScanner
|
||||
let totalStandard = 0;
|
||||
let total4k = 0;
|
||||
|
||||
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) {
|
||||
if (!this.enable4kShow) {
|
||||
const episodes = await this.jfClient.getEpisodes(
|
||||
Id,
|
||||
matchedJellyfinSeason.Id
|
||||
@@ -447,6 +362,14 @@ 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;
|
||||
}
|
||||
@@ -529,8 +452,6 @@ class JellyfinScanner
|
||||
|
||||
const sessionId = this.startRun();
|
||||
|
||||
serviceAvailabilityChecker.clearCache();
|
||||
|
||||
try {
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOne({
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
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;
|
||||
@@ -296,6 +296,7 @@ export interface NotificationAgentNtfy extends NotificationAgentConfig {
|
||||
password?: string;
|
||||
authMethodToken?: boolean;
|
||||
token?: string;
|
||||
priority?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -529,6 +530,7 @@ class Settings {
|
||||
options: {
|
||||
url: '',
|
||||
topic: '',
|
||||
priority: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -13,9 +13,7 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
|
||||
}
|
||||
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find({
|
||||
select: ['id'],
|
||||
});
|
||||
const users = await userRepository.find();
|
||||
|
||||
let errorOccurred = false;
|
||||
|
||||
@@ -30,15 +28,26 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
|
||||
});
|
||||
const radarrTags = await radarr.getTags();
|
||||
for (const user of users) {
|
||||
const userTag = radarrTags.find((v) =>
|
||||
v.label.startsWith(user.id + ' - ')
|
||||
const userTag = radarrTags.find(
|
||||
(v) =>
|
||||
v.label.startsWith(user.id + ' - ') ||
|
||||
v.label.startsWith(user.id + '-')
|
||||
);
|
||||
if (!userTag) {
|
||||
continue;
|
||||
}
|
||||
await radarr.renameTag({
|
||||
id: userTag.id,
|
||||
label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
|
||||
label:
|
||||
user.id +
|
||||
'-' +
|
||||
user.displayName
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/gi, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, ''),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -61,15 +70,26 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
|
||||
});
|
||||
const sonarrTags = await sonarr.getTags();
|
||||
for (const user of users) {
|
||||
const userTag = sonarrTags.find((v) =>
|
||||
v.label.startsWith(user.id + ' - ')
|
||||
const userTag = sonarrTags.find(
|
||||
(v) =>
|
||||
v.label.startsWith(user.id + ' - ') ||
|
||||
v.label.startsWith(user.id + '-')
|
||||
);
|
||||
if (!userTag) {
|
||||
continue;
|
||||
}
|
||||
await sonarr.renameTag({
|
||||
id: userTag.id,
|
||||
label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
|
||||
label:
|
||||
user.id +
|
||||
'-' +
|
||||
user.displayName
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/gi, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, ''),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -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"`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import TautulliAPI from '@server/api/tautulli';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { UserType } from '@server/constants/user';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import dataSource, { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import { User } from '@server/entity/User';
|
||||
@@ -25,7 +25,8 @@ import { getHostname } from '@server/utils/getHostname';
|
||||
import { Router } from 'express';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
import { findIndex, sortBy } from 'lodash';
|
||||
import { In } from 'typeorm';
|
||||
import type { EntityManager } from 'typeorm';
|
||||
import { In, Not } from 'typeorm';
|
||||
import userSettingsRoutes from './usersettings';
|
||||
|
||||
const router = Router();
|
||||
@@ -188,30 +189,82 @@ router.post<
|
||||
}
|
||||
>('/registerPushSubscription', async (req, res, next) => {
|
||||
try {
|
||||
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||
// This prevents race conditions where two requests both pass the checks
|
||||
await dataSource.transaction(
|
||||
async (transactionalEntityManager: EntityManager) => {
|
||||
const transactionalRepo =
|
||||
transactionalEntityManager.getRepository(UserPushSubscription);
|
||||
|
||||
const existingSubs = await userPushSubRepository.find({
|
||||
relations: { user: true },
|
||||
where: { auth: req.body.auth, user: { id: req.user?.id } },
|
||||
});
|
||||
// Check for existing subscription by auth or endpoint within transaction
|
||||
const existingSubscription = await transactionalRepo.findOne({
|
||||
relations: { user: true },
|
||||
where: [
|
||||
{ auth: req.body.auth, user: { id: req.user?.id } },
|
||||
{ endpoint: req.body.endpoint, user: { id: req.user?.id } },
|
||||
],
|
||||
});
|
||||
|
||||
if (existingSubs.length > 0) {
|
||||
logger.debug(
|
||||
'User push subscription already exists. Skipping registration.',
|
||||
{ label: 'API' }
|
||||
);
|
||||
return res.status(204).send();
|
||||
}
|
||||
if (existingSubscription) {
|
||||
// If endpoint matches but auth is different, update with new keys (iOS refresh case)
|
||||
if (
|
||||
existingSubscription.endpoint === req.body.endpoint &&
|
||||
existingSubscription.auth !== req.body.auth
|
||||
) {
|
||||
existingSubscription.auth = req.body.auth;
|
||||
existingSubscription.p256dh = req.body.p256dh;
|
||||
existingSubscription.userAgent = req.body.userAgent;
|
||||
|
||||
const userPushSubscription = new UserPushSubscription({
|
||||
auth: req.body.auth,
|
||||
endpoint: req.body.endpoint,
|
||||
p256dh: req.body.p256dh,
|
||||
userAgent: req.body.userAgent,
|
||||
user: req.user,
|
||||
});
|
||||
await transactionalRepo.save(existingSubscription);
|
||||
|
||||
userPushSubRepository.save(userPushSubscription);
|
||||
logger.debug(
|
||||
'Updated existing push subscription with new keys for same endpoint.',
|
||||
{ label: 'API' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
'Duplicate subscription detected. Skipping registration.',
|
||||
{ label: 'API' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up old subscriptions from the same device (userAgent) for this user
|
||||
// iOS can silently refresh endpoints, leaving stale subscriptions in the database
|
||||
// Only clean up if we're creating a new subscription (not updating an existing one)
|
||||
if (req.body.userAgent) {
|
||||
const staleSubscriptions = await transactionalRepo.find({
|
||||
relations: { user: true },
|
||||
where: {
|
||||
userAgent: req.body.userAgent,
|
||||
user: { id: req.user?.id },
|
||||
// Only remove subscriptions with different endpoints (stale ones)
|
||||
// Keep subscriptions that might be from different browsers/tabs
|
||||
endpoint: Not(req.body.endpoint),
|
||||
},
|
||||
});
|
||||
|
||||
if (staleSubscriptions.length > 0) {
|
||||
await transactionalRepo.remove(staleSubscriptions);
|
||||
logger.debug(
|
||||
`Removed ${staleSubscriptions.length} stale push subscription(s) from same device.`,
|
||||
{ label: 'API' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userPushSubscription = new UserPushSubscription({
|
||||
auth: req.body.auth,
|
||||
endpoint: req.body.endpoint,
|
||||
p256dh: req.body.p256dh,
|
||||
userAgent: req.body.userAgent,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
await transactionalRepo.save(userPushSubscription);
|
||||
}
|
||||
);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
|
||||
@@ -29,6 +29,16 @@ import type {
|
||||
} from 'typeorm';
|
||||
import { EventSubscriber } from 'typeorm';
|
||||
|
||||
const sanitizeDisplayName = (displayName: string): string => {
|
||||
return displayName
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/gi, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
};
|
||||
|
||||
@EventSubscriber()
|
||||
export class MediaRequestSubscriber
|
||||
implements EntitySubscriberInterface<MediaRequest>
|
||||
@@ -310,11 +320,15 @@ export class MediaRequestSubscriber
|
||||
mediaId: entity.media.id,
|
||||
userId: entity.requestedBy.id,
|
||||
newTag:
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
entity.requestedBy.id +
|
||||
'-' +
|
||||
sanitizeDisplayName(entity.requestedBy.displayName),
|
||||
});
|
||||
userTag = await radarr.createTag({
|
||||
label:
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
entity.requestedBy.id +
|
||||
'-' +
|
||||
sanitizeDisplayName(entity.requestedBy.displayName),
|
||||
});
|
||||
}
|
||||
if (userTag.id) {
|
||||
@@ -631,11 +645,15 @@ export class MediaRequestSubscriber
|
||||
mediaId: entity.media.id,
|
||||
userId: entity.requestedBy.id,
|
||||
newTag:
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
entity.requestedBy.id +
|
||||
'-' +
|
||||
sanitizeDisplayName(entity.requestedBy.displayName),
|
||||
});
|
||||
userTag = await sonarr.createTag({
|
||||
label:
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
entity.requestedBy.id +
|
||||
'-' +
|
||||
sanitizeDisplayName(entity.requestedBy.displayName),
|
||||
});
|
||||
}
|
||||
if (userTag.id) {
|
||||
|
||||
@@ -3,6 +3,7 @@ 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', {
|
||||
@@ -46,8 +47,12 @@ const PlexLoginButton = ({
|
||||
>
|
||||
{(chunks) => (
|
||||
<>
|
||||
{chunks.map((c) =>
|
||||
typeof c === 'string' ? <span>{c}</span> : c
|
||||
{chunks.map((c, index) =>
|
||||
typeof c === 'string' ? (
|
||||
<span key={index}>{c}</span>
|
||||
) : (
|
||||
<Fragment key={index}>{c}</Fragment>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfaces';
|
||||
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
@@ -33,6 +34,17 @@ import Link from 'next/link';
|
||||
import { useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const filterDuplicateDownloads = (
|
||||
items: DownloadingItem[] = []
|
||||
): DownloadingItem[] => {
|
||||
const seen = new Set<string>();
|
||||
return items.filter((item) => {
|
||||
if (seen.has(item.downloadId)) return false;
|
||||
seen.add(item.downloadId);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const messages = defineMessages('components.ManageSlideOver', {
|
||||
manageModalTitle: 'Manage {mediaType}',
|
||||
manageModalIssues: 'Open Issues',
|
||||
@@ -230,26 +242,30 @@ const ManageSlideOver = ({
|
||||
</h3>
|
||||
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
||||
<Tooltip
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
content={status.title}
|
||||
>
|
||||
<li className="border-b border-gray-700 last:border-b-0">
|
||||
<DownloadBlock downloadItem={status} />
|
||||
</li>
|
||||
</Tooltip>
|
||||
))}
|
||||
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
||||
<Tooltip
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
content={status.title}
|
||||
>
|
||||
<li className="border-b border-gray-700 last:border-b-0">
|
||||
<DownloadBlock downloadItem={status} is4k />
|
||||
</li>
|
||||
</Tooltip>
|
||||
))}
|
||||
{filterDuplicateDownloads(data.mediaInfo?.downloadStatus).map(
|
||||
(status, index) => (
|
||||
<Tooltip
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
content={status.title}
|
||||
>
|
||||
<li className="border-b border-gray-700 last:border-b-0">
|
||||
<DownloadBlock downloadItem={status} />
|
||||
</li>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
{filterDuplicateDownloads(data.mediaInfo?.downloadStatus4k).map(
|
||||
(status, index) => (
|
||||
<Tooltip
|
||||
key={`dl-status-4k-${status.externalId}-${index}`}
|
||||
content={status.title}
|
||||
>
|
||||
<li className="border-b border-gray-700 last:border-b-0">
|
||||
<DownloadBlock downloadItem={status} is4k />
|
||||
</li>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ const messages = defineMessages(
|
||||
password: 'Password',
|
||||
tokenAuth: 'Token authentication',
|
||||
token: 'Token',
|
||||
priority: 'Priority',
|
||||
ntfysettingssaved: 'Ntfy notification settings saved successfully!',
|
||||
ntfysettingsfailed: 'Ntfy notification settings failed to save.',
|
||||
toastNtfyTestSending: 'Sending ntfy test notification…',
|
||||
@@ -34,6 +35,7 @@ const messages = defineMessages(
|
||||
toastNtfyTestFailed: 'Ntfy test notification failed to send.',
|
||||
validationNtfyUrl: 'You must provide a valid URL',
|
||||
validationNtfyTopic: 'You must provide a topic',
|
||||
validationPriorityRequired: 'You must provide a priority between 1 and 5',
|
||||
validationTypes: 'You must select at least one notification type',
|
||||
}
|
||||
);
|
||||
@@ -71,6 +73,14 @@ const NotificationsNtfy = () => {
|
||||
otherwise: Yup.string().nullable(),
|
||||
})
|
||||
.defined(intl.formatMessage(messages.validationNtfyTopic)),
|
||||
priority: Yup.number().when('enabled', {
|
||||
is: true,
|
||||
then: Yup.number()
|
||||
.min(1)
|
||||
.max(5)
|
||||
.required(intl.formatMessage(messages.validationPriorityRequired)),
|
||||
otherwise: Yup.number().nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!data && !error) {
|
||||
@@ -90,6 +100,7 @@ const NotificationsNtfy = () => {
|
||||
password: data?.options.password,
|
||||
authMethodToken: data?.options.authMethodToken,
|
||||
token: data?.options.token,
|
||||
priority: data?.options.priority,
|
||||
}}
|
||||
validationSchema={NotificationsNtfySchema}
|
||||
onSubmit={async (values) => {
|
||||
@@ -106,6 +117,7 @@ const NotificationsNtfy = () => {
|
||||
password: values.password,
|
||||
authMethodToken: values.authMethodToken,
|
||||
token: values.token,
|
||||
priority: values.priority,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -157,6 +169,7 @@ const NotificationsNtfy = () => {
|
||||
password: values.password,
|
||||
authMethodToken: values.authMethodToken,
|
||||
token: values.token,
|
||||
priority: values.priority,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -313,6 +326,22 @@ const NotificationsNtfy = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-row">
|
||||
<label htmlFor="priority" className="text-label">
|
||||
{intl.formatMessage(messages.priority)}
|
||||
</label>
|
||||
<div className="form-input-area">
|
||||
<div className="form-input-field">
|
||||
<Field as="select" id="priority" name="priority">
|
||||
<option value={1}>Minimum</option>
|
||||
<option value={2}>Low</option>
|
||||
<option value={3}>Default</option>
|
||||
<option value={4}>High</option>
|
||||
<option value={5}>Urgent</option>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NotificationTypeSelector
|
||||
currentTypes={values.enabled ? values.types || 0 : 0}
|
||||
onUpdate={(newTypes) => {
|
||||
|
||||
@@ -377,6 +377,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
|
||||
webAppUrl: data?.webAppUrl,
|
||||
}}
|
||||
validationSchema={PlexSettingsSchema}
|
||||
validateOnMount={true}
|
||||
onSubmit={async (values) => {
|
||||
let toastId: string | null = null;
|
||||
try {
|
||||
@@ -423,6 +424,7 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
|
||||
values,
|
||||
handleSubmit,
|
||||
setFieldValue,
|
||||
setValues,
|
||||
isSubmitting,
|
||||
isValid,
|
||||
}) => {
|
||||
@@ -445,9 +447,12 @@ const SettingsPlex = ({ onComplete }: SettingsPlexProps) => {
|
||||
availablePresets[Number(e.target.value)];
|
||||
|
||||
if (targPreset) {
|
||||
setFieldValue('hostname', targPreset.address);
|
||||
setFieldValue('port', targPreset.port);
|
||||
setFieldValue('useSsl', targPreset.ssl);
|
||||
setValues({
|
||||
...values,
|
||||
hostname: targPreset.address,
|
||||
port: targPreset.port,
|
||||
useSsl: targPreset.ssl,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -28,7 +28,8 @@ const LoginWithPlex = ({ onComplete }: LoginWithPlexProps) => {
|
||||
const response = await axios.post('/api/v1/auth/plex', { authToken });
|
||||
|
||||
if (response.data?.id) {
|
||||
revalidate();
|
||||
const { data: user } = await axios.get('/api/v1/auth/me');
|
||||
revalidate(user, false);
|
||||
}
|
||||
};
|
||||
if (authToken) {
|
||||
|
||||
@@ -109,15 +109,28 @@ const UserWebPushSettings = () => {
|
||||
// Deletes/disables corresponding push subscription from database
|
||||
const disablePushNotifications = async (endpoint?: string) => {
|
||||
try {
|
||||
await unsubscribeToPushNotifications(user?.id, endpoint);
|
||||
|
||||
// Delete from backend if endpoint is available
|
||||
if (subEndpoint) {
|
||||
await deletePushSubscriptionFromBackend(subEndpoint);
|
||||
}
|
||||
const unsubscribedEndpoint = await unsubscribeToPushNotifications(
|
||||
user?.id,
|
||||
endpoint
|
||||
);
|
||||
|
||||
localStorage.setItem('pushNotificationsEnabled', 'false');
|
||||
setWebPushEnabled(false);
|
||||
|
||||
// Only delete the current browser's subscription, not all devices
|
||||
const endpointToDelete = unsubscribedEndpoint || subEndpoint || endpoint;
|
||||
if (endpointToDelete) {
|
||||
try {
|
||||
await axios.delete(
|
||||
`/api/v1/user/${user?.id}/pushSubscription/${encodeURIComponent(
|
||||
endpointToDelete
|
||||
)}`
|
||||
);
|
||||
} catch {
|
||||
// Ignore deletion failures - backend cleanup is best effort
|
||||
}
|
||||
}
|
||||
|
||||
addToast(intl.formatMessage(messages.webpushhasbeendisabled), {
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
@@ -157,7 +170,33 @@ const UserWebPushSettings = () => {
|
||||
useEffect(() => {
|
||||
const verifyWebPush = async () => {
|
||||
const enabled = await verifyPushSubscription(user?.id, currentSettings);
|
||||
setWebPushEnabled(enabled);
|
||||
let isEnabled = enabled;
|
||||
|
||||
if (!enabled && 'serviceWorker' in navigator) {
|
||||
const { subscription } = await getPushSubscription();
|
||||
if (subscription) {
|
||||
isEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEnabled && dataDevices && dataDevices.length > 0) {
|
||||
const currentUserAgent = navigator.userAgent;
|
||||
const hasMatchingDevice = dataDevices.some(
|
||||
(device) => device.userAgent === currentUserAgent
|
||||
);
|
||||
|
||||
if (hasMatchingDevice) {
|
||||
isEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
setWebPushEnabled(isEnabled);
|
||||
if (localStorage.getItem('pushNotificationsEnabled') === null) {
|
||||
localStorage.setItem(
|
||||
'pushNotificationsEnabled',
|
||||
isEnabled ? 'true' : 'false'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (user?.id) {
|
||||
|
||||
@@ -629,6 +629,7 @@
|
||||
"components.Settings.Notifications.NotificationsNtfy.ntfysettingsfailed": "Ntfy notification settings failed to save.",
|
||||
"components.Settings.Notifications.NotificationsNtfy.ntfysettingssaved": "Ntfy notification settings saved successfully!",
|
||||
"components.Settings.Notifications.NotificationsNtfy.password": "Password",
|
||||
"components.Settings.Notifications.NotificationsNtfy.priority": "Priority",
|
||||
"components.Settings.Notifications.NotificationsNtfy.toastNtfyTestFailed": "Ntfy test notification failed to send.",
|
||||
"components.Settings.Notifications.NotificationsNtfy.toastNtfyTestSending": "Sending ntfy test notification…",
|
||||
"components.Settings.Notifications.NotificationsNtfy.toastNtfyTestSuccess": "Ntfy test notification sent!",
|
||||
@@ -640,6 +641,7 @@
|
||||
"components.Settings.Notifications.NotificationsNtfy.usernamePasswordAuth": "Username + Password authentication",
|
||||
"components.Settings.Notifications.NotificationsNtfy.validationNtfyTopic": "You must provide a topic",
|
||||
"components.Settings.Notifications.NotificationsNtfy.validationNtfyUrl": "You must provide a valid URL",
|
||||
"components.Settings.Notifications.NotificationsNtfy.validationPriorityRequired": "You must provide a priority between 1 and 5",
|
||||
"components.Settings.Notifications.NotificationsNtfy.validationTypes": "You must select at least one notification type",
|
||||
"components.Settings.Notifications.NotificationsPushbullet.accessToken": "Access Token",
|
||||
"components.Settings.Notifications.NotificationsPushbullet.accessTokenTip": "Create a token from your <PushbulletSettingsLink>Account Settings</PushbulletSettingsLink>",
|
||||
|
||||
@@ -49,13 +49,17 @@ export const verifyPushSubscription = async (
|
||||
currentSettings.vapidPublic
|
||||
).toString();
|
||||
|
||||
if (currentServerKey !== expectedServerKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endpoint = subscription.endpoint;
|
||||
|
||||
const { data } = await axios.get<UserPushSubscription>(
|
||||
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(endpoint)}`
|
||||
);
|
||||
|
||||
return expectedServerKey === currentServerKey && data.endpoint === endpoint;
|
||||
return data.endpoint === endpoint;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -65,20 +69,39 @@ export const verifyAndResubscribePushSubscription = async (
|
||||
userId: number | undefined,
|
||||
currentSettings: PublicSettingsResponse
|
||||
): Promise<boolean> => {
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { subscription } = await getPushSubscription();
|
||||
const isValid = await verifyPushSubscription(userId, currentSettings);
|
||||
|
||||
if (isValid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (subscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentSettings.enablePushRegistration) {
|
||||
try {
|
||||
// Unsubscribe from the backend to clear the existing push subscription (keys and endpoint)
|
||||
await unsubscribeToPushNotifications(userId);
|
||||
const oldEndpoint = await unsubscribeToPushNotifications(userId);
|
||||
|
||||
// Subscribe again to generate a fresh push subscription with updated keys and endpoint
|
||||
await subscribeToPushNotifications(userId, currentSettings);
|
||||
|
||||
if (oldEndpoint) {
|
||||
try {
|
||||
await axios.delete(
|
||||
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(
|
||||
oldEndpoint
|
||||
)}`
|
||||
);
|
||||
} catch (error) {
|
||||
// Ignore errors when deleting old endpoint (it might not exist)
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new Error(`[SW] Resubscribe failed: ${error.message}`);
|
||||
@@ -136,24 +159,26 @@ export const subscribeToPushNotifications = async (
|
||||
export const unsubscribeToPushNotifications = async (
|
||||
userId: number | undefined,
|
||||
endpoint?: string
|
||||
) => {
|
||||
): Promise<string | null> => {
|
||||
if (!('serviceWorker' in navigator) || !userId) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { subscription } = await getPushSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const { endpoint: currentEndpoint } = subscription.toJSON();
|
||||
|
||||
if (!endpoint || endpoint === currentEndpoint) {
|
||||
await subscription.unsubscribe();
|
||||
return true;
|
||||
return currentEndpoint ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Issue unsubscribing to push notifications: ${error.message}`
|
||||
|
||||
Reference in New Issue
Block a user