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

Compare commits

..

2 Commits

Author SHA1 Message Date
fallenbagel
cb672ec3c4 docs: temporarily make it clear seerr is not released 2026-01-03 04:49:39 +05:00
0xsysr3ll
d0c9afc16e fix(webpush): improve iOS push subscription endpoint cleanup (#2140) 2025-12-31 13:44:45 +01:00
10 changed files with 254 additions and 328 deletions

View File

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

View File

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

View File

@@ -24,6 +24,15 @@ interface PushNotificationPayload {
isAdmin?: boolean;
}
interface WebPushError extends Error {
statusCode?: number;
status?: number;
body?: string | unknown;
response?: {
body?: string | unknown;
};
}
class WebPushAgent
extends BaseAgent<NotificationAgentConfig>
implements NotificationAgent
@@ -188,19 +197,30 @@ class WebPushAgent
notificationPayload
);
} catch (e) {
const webPushError = e as WebPushError;
const statusCode = webPushError.statusCode || webPushError.status;
const errorMessage = webPushError.message || String(e);
// RFC 8030: 410/404 are permanent failures, others are transient
const isPermanentFailure = statusCode === 410 || statusCode === 404;
logger.error(
'Error sending web push notification; removing subscription',
isPermanentFailure
? 'Error sending web push notification; removing invalid subscription'
: 'Error sending web push notification (transient error, keeping subscription)',
{
label: 'Notifications',
recipient: pushSub.user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
errorMessage,
statusCode: statusCode || 'unknown',
}
);
// Failed to send notification so we need to remove the subscription
userPushSubRepository.remove(pushSub);
if (isPermanentFailure) {
await userPushSubRepository.remove(pushSub);
}
}
};

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
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"`
);
}
}

View File

@@ -0,0 +1,17 @@
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"`);
}
}

View File

@@ -4,7 +4,7 @@ import TautulliAPI from '@server/api/tautulli';
import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource';
import dataSource, { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest';
import { User } from '@server/entity/User';
@@ -25,7 +25,8 @@ import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express';
import gravatarUrl from 'gravatar-url';
import { findIndex, sortBy } from 'lodash';
import { In } from 'typeorm';
import type { EntityManager } from 'typeorm';
import { In, Not } from 'typeorm';
import userSettingsRoutes from './usersettings';
const router = Router();
@@ -188,30 +189,82 @@ router.post<
}
>('/registerPushSubscription', async (req, res, next) => {
try {
const userPushSubRepository = getRepository(UserPushSubscription);
// This prevents race conditions where two requests both pass the checks
await dataSource.transaction(
async (transactionalEntityManager: EntityManager) => {
const transactionalRepo =
transactionalEntityManager.getRepository(UserPushSubscription);
const existingSubs = await userPushSubRepository.find({
relations: { user: true },
where: { auth: req.body.auth, user: { id: req.user?.id } },
});
// Check for existing subscription by auth or endpoint within transaction
const existingSubscription = await transactionalRepo.findOne({
relations: { user: true },
where: [
{ auth: req.body.auth, user: { id: req.user?.id } },
{ endpoint: req.body.endpoint, user: { id: req.user?.id } },
],
});
if (existingSubs.length > 0) {
logger.debug(
'User push subscription already exists. Skipping registration.',
{ label: 'API' }
);
return res.status(204).send();
}
if (existingSubscription) {
// If endpoint matches but auth is different, update with new keys (iOS refresh case)
if (
existingSubscription.endpoint === req.body.endpoint &&
existingSubscription.auth !== req.body.auth
) {
existingSubscription.auth = req.body.auth;
existingSubscription.p256dh = req.body.p256dh;
existingSubscription.userAgent = req.body.userAgent;
const userPushSubscription = new UserPushSubscription({
auth: req.body.auth,
endpoint: req.body.endpoint,
p256dh: req.body.p256dh,
userAgent: req.body.userAgent,
user: req.user,
});
await transactionalRepo.save(existingSubscription);
userPushSubRepository.save(userPushSubscription);
logger.debug(
'Updated existing push subscription with new keys for same endpoint.',
{ label: 'API' }
);
return;
}
logger.debug(
'Duplicate subscription detected. Skipping registration.',
{ label: 'API' }
);
return;
}
// Clean up old subscriptions from the same device (userAgent) for this user
// iOS can silently refresh endpoints, leaving stale subscriptions in the database
// Only clean up if we're creating a new subscription (not updating an existing one)
if (req.body.userAgent) {
const staleSubscriptions = await transactionalRepo.find({
relations: { user: true },
where: {
userAgent: req.body.userAgent,
user: { id: req.user?.id },
// Only remove subscriptions with different endpoints (stale ones)
// Keep subscriptions that might be from different browsers/tabs
endpoint: Not(req.body.endpoint),
},
});
if (staleSubscriptions.length > 0) {
await transactionalRepo.remove(staleSubscriptions);
logger.debug(
`Removed ${staleSubscriptions.length} stale push subscription(s) from same device.`,
{ label: 'API' }
);
}
}
const userPushSubscription = new UserPushSubscription({
auth: req.body.auth,
endpoint: req.body.endpoint,
p256dh: req.body.p256dh,
userAgent: req.body.userAgent,
user: req.user,
});
await transactionalRepo.save(userPushSubscription);
}
);
return res.status(204).send();
} catch (e) {

View File

@@ -109,15 +109,28 @@ const UserWebPushSettings = () => {
// Deletes/disables corresponding push subscription from database
const disablePushNotifications = async (endpoint?: string) => {
try {
await unsubscribeToPushNotifications(user?.id, endpoint);
// Delete from backend if endpoint is available
if (subEndpoint) {
await deletePushSubscriptionFromBackend(subEndpoint);
}
const unsubscribedEndpoint = await unsubscribeToPushNotifications(
user?.id,
endpoint
);
localStorage.setItem('pushNotificationsEnabled', 'false');
setWebPushEnabled(false);
// Only delete the current browser's subscription, not all devices
const endpointToDelete = unsubscribedEndpoint || subEndpoint || endpoint;
if (endpointToDelete) {
try {
await axios.delete(
`/api/v1/user/${user?.id}/pushSubscription/${encodeURIComponent(
endpointToDelete
)}`
);
} catch {
// Ignore deletion failures - backend cleanup is best effort
}
}
addToast(intl.formatMessage(messages.webpushhasbeendisabled), {
autoDismiss: true,
appearance: 'success',
@@ -157,7 +170,33 @@ const UserWebPushSettings = () => {
useEffect(() => {
const verifyWebPush = async () => {
const enabled = await verifyPushSubscription(user?.id, currentSettings);
setWebPushEnabled(enabled);
let isEnabled = enabled;
if (!enabled && 'serviceWorker' in navigator) {
const { subscription } = await getPushSubscription();
if (subscription) {
isEnabled = true;
}
}
if (!isEnabled && dataDevices && dataDevices.length > 0) {
const currentUserAgent = navigator.userAgent;
const hasMatchingDevice = dataDevices.some(
(device) => device.userAgent === currentUserAgent
);
if (hasMatchingDevice) {
isEnabled = true;
}
}
setWebPushEnabled(isEnabled);
if (localStorage.getItem('pushNotificationsEnabled') === null) {
localStorage.setItem(
'pushNotificationsEnabled',
isEnabled ? 'true' : 'false'
);
}
};
if (user?.id) {

View File

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