Compare commits
16 Commits
fallenbage
...
preview-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac5e2ba6c1 | ||
|
|
bb2120c14d | ||
|
|
c9037f77e6 | ||
|
|
48631db989 | ||
|
|
ac7c2983d3 | ||
|
|
767dc529e8 | ||
|
|
448a25e2a4 | ||
|
|
3f35b8c886 | ||
|
|
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
|
## 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">
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'} ${
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 { 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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
|||||||
Reference in New Issue
Block a user