Compare commits
23 Commits
preview-mu
...
fix/0xsysr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed3a9b1c8d | ||
|
|
8431ef3f3b | ||
|
|
88b2e7843f | ||
|
|
dbd5935ade | ||
|
|
bb2120c14d | ||
|
|
c9037f77e6 | ||
|
|
48631db989 | ||
|
|
ac7c2983d3 | ||
|
|
767dc529e8 | ||
|
|
448a25e2a4 | ||
|
|
3f35b8c886 | ||
|
|
d0f029b46e | ||
|
|
e0a81038cd | ||
|
|
4ab919360a | ||
|
|
adbcf80333 | ||
|
|
f91a26befe | ||
|
|
0c95b5ec91 | ||
|
|
193d4dc668 | ||
|
|
d0c9afc16e | ||
|
|
57d583e1bd | ||
|
|
8bbe7864af | ||
|
|
66b4e2c871 | ||
|
|
3ee69663dc |
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -91,6 +91,14 @@ body:
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Please provide any additional information that may be relevant or helpful.
|
||||
- type: checkboxes
|
||||
id: search-existing
|
||||
attributes:
|
||||
label: Search Existing Issues
|
||||
description: Have you searched existing issues to see if this bug has already been reported?
|
||||
options:
|
||||
- label: Yes, I have searched existing issues.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
8
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
@@ -27,6 +27,14 @@ body:
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Provide any additional information or screenshots that may be relevant or helpful.
|
||||
- type: checkboxes
|
||||
id: search-existing
|
||||
attributes:
|
||||
label: Search Existing Issues
|
||||
description: Have you searched existing issues to see if this feature has already been requested?
|
||||
options:
|
||||
- label: Yes, I have searched existing issues.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
|
||||
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">
|
||||
|
||||
@@ -209,6 +209,34 @@ class SonarrAPI extends ServarrBase<{
|
||||
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) {
|
||||
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(
|
||||
seasons: number[],
|
||||
existingSeasons?: SonarrSeason[]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -97,7 +97,10 @@ app
|
||||
|
||||
// Register HTTP proxy
|
||||
if (settings.network.proxy.enabled) {
|
||||
await createCustomProxyAgent(settings.network.proxy);
|
||||
await createCustomProxyAgent(
|
||||
settings.network.proxy,
|
||||
settings.network.forceIpv4First
|
||||
);
|
||||
}
|
||||
|
||||
// Migrate library types
|
||||
|
||||
@@ -143,7 +143,9 @@ class AvailabilitySync {
|
||||
const { existsInPlex: existsInPlex4k } =
|
||||
await this.mediaExistsInPlex(media, true);
|
||||
|
||||
if (existsInPlex || existsInRadarr) {
|
||||
// Media must exist in Plex to be considered available
|
||||
// If it exists in Radarr but not in Plex, it should be marked as deleted
|
||||
if (existsInPlex) {
|
||||
movieExists = true;
|
||||
logger.info(
|
||||
`The non-4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||
@@ -153,7 +155,7 @@ class AvailabilitySync {
|
||||
);
|
||||
}
|
||||
|
||||
if (existsInPlex4k || existsInRadarr4k) {
|
||||
if (existsInPlex4k) {
|
||||
movieExists4k = true;
|
||||
logger.info(
|
||||
`The 4K movie [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||
@@ -240,7 +242,9 @@ class AvailabilitySync {
|
||||
|
||||
//plex
|
||||
if (mediaServerType === MediaServerType.PLEX) {
|
||||
if (existsInPlex || existsInSonarr) {
|
||||
// Media must exist in Plex to be considered available
|
||||
// If it exists in Sonarr but not in Plex, it should be marked as deleted
|
||||
if (existsInPlex) {
|
||||
showExists = true;
|
||||
logger.info(
|
||||
`The non-4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||
@@ -252,7 +256,7 @@ class AvailabilitySync {
|
||||
}
|
||||
|
||||
if (mediaServerType === MediaServerType.PLEX) {
|
||||
if (existsInPlex4k || existsInSonarr4k) {
|
||||
if (existsInPlex4k) {
|
||||
showExists4k = true;
|
||||
logger.info(
|
||||
`The 4K show [TMDB ID ${media.tmdbId}] still exists. Preventing removal.`,
|
||||
@@ -300,7 +304,6 @@ class AvailabilitySync {
|
||||
// Sonarr finds that season, we will change the final seasons value
|
||||
// to true.
|
||||
const filteredSeasonsMap: Map<number, boolean> = new Map();
|
||||
|
||||
media.seasons
|
||||
.filter(
|
||||
(season) =>
|
||||
@@ -311,48 +314,7 @@ class AvailabilitySync {
|
||||
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();
|
||||
|
||||
media.seasons
|
||||
.filter(
|
||||
(season) =>
|
||||
@@ -363,44 +325,32 @@ class AvailabilitySync {
|
||||
filteredSeasonsMap4k.set(season.seasonNumber, false)
|
||||
);
|
||||
|
||||
// 4k
|
||||
const finalSeasons4k: Map<number, boolean> = new Map();
|
||||
let finalSeasons: Map<number, boolean>;
|
||||
let finalSeasons4k: Map<number, boolean>;
|
||||
|
||||
if (mediaServerType === MediaServerType.PLEX) {
|
||||
plexSeasonsMap4k.forEach((value, key) => {
|
||||
finalSeasons4k.set(key, value);
|
||||
});
|
||||
|
||||
filteredSeasonsMap4k.forEach((value, key) => {
|
||||
if (!finalSeasons4k.has(key)) {
|
||||
finalSeasons4k.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
sonarrSeasonsMap4k.forEach((value, key) => {
|
||||
if (!finalSeasons4k.has(key)) {
|
||||
finalSeasons4k.set(key, value);
|
||||
}
|
||||
});
|
||||
} else if (
|
||||
mediaServerType === MediaServerType.JELLYFIN ||
|
||||
mediaServerType === MediaServerType.EMBY
|
||||
) {
|
||||
jellyfinSeasonsMap4k.forEach((value, key) => {
|
||||
finalSeasons4k.set(key, value);
|
||||
});
|
||||
|
||||
filteredSeasonsMap4k.forEach((value, key) => {
|
||||
if (!finalSeasons4k.has(key)) {
|
||||
finalSeasons4k.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
sonarrSeasonsMap4k.forEach((value, key) => {
|
||||
if (!finalSeasons4k.has(key)) {
|
||||
finalSeasons4k.set(key, value);
|
||||
}
|
||||
});
|
||||
finalSeasons = new Map([
|
||||
...filteredSeasonsMap,
|
||||
...plexSeasonsMap,
|
||||
...sonarrSeasonsMap,
|
||||
]);
|
||||
finalSeasons4k = new Map([
|
||||
...filteredSeasonsMap4k,
|
||||
...plexSeasonsMap4k,
|
||||
...sonarrSeasonsMap4k,
|
||||
]);
|
||||
} else {
|
||||
// Jellyfin/Emby
|
||||
finalSeasons = new Map([
|
||||
...filteredSeasonsMap,
|
||||
...jellyfinSeasonsMap,
|
||||
...sonarrSeasonsMap,
|
||||
]);
|
||||
finalSeasons4k = new Map([
|
||||
...filteredSeasonsMap4k,
|
||||
...jellyfinSeasonsMap4k,
|
||||
...sonarrSeasonsMap4k,
|
||||
]);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -665,6 +615,21 @@ class AvailabilitySync {
|
||||
is4k: boolean
|
||||
): Promise<boolean> {
|
||||
let existsInRadarr = false;
|
||||
const externalServiceId = is4k
|
||||
? media.externalServiceId4k
|
||||
: media.externalServiceId;
|
||||
|
||||
if (!externalServiceId) {
|
||||
logger.debug(
|
||||
`Skipping Radarr check for ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${
|
||||
media.tmdbId
|
||||
}] - no externalServiceId available`,
|
||||
{
|
||||
label: 'Availability Sync',
|
||||
}
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for availability in all of the available radarr servers
|
||||
// If any find the media, we will assume the media exists
|
||||
@@ -691,22 +656,63 @@ class AvailabilitySync {
|
||||
});
|
||||
}
|
||||
|
||||
if (radarr && radarr.hasFile) {
|
||||
if (radarr) {
|
||||
if (radarr.hasFile) {
|
||||
const resolution =
|
||||
radarr?.movieFile?.mediaInfo?.resolution?.split('x');
|
||||
const is4kMovie =
|
||||
resolution?.length === 2 && Number(resolution[0]) >= 2000;
|
||||
existsInRadarr = is4k ? is4kMovie : !is4kMovie;
|
||||
const matches4k = is4k ? is4kMovie : !is4kMovie;
|
||||
if (matches4k) {
|
||||
existsInRadarr = true;
|
||||
logger.debug(
|
||||
`Found ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${
|
||||
media.tmdbId
|
||||
}] in Radarr`,
|
||||
{
|
||||
radarrId: radarr.id,
|
||||
radarrTitle: radarr.title,
|
||||
hasFile: radarr.hasFile,
|
||||
externalServiceId: externalServiceId,
|
||||
label: 'Availability Sync',
|
||||
}
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Movie [TMDB ID ${media.tmdbId}] found in Radarr but resolution doesn't match (is4k: ${is4k}, movie resolution: ${radarr?.movieFile?.mediaInfo?.resolution})`,
|
||||
{
|
||||
label: 'Availability Sync',
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.debug(
|
||||
`Movie [TMDB ID ${media.tmdbId}] found in Radarr but has no file`,
|
||||
{
|
||||
radarrId: radarr.id,
|
||||
radarrTitle: radarr.title,
|
||||
externalServiceId: externalServiceId,
|
||||
label: 'Availability Sync',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404')) {
|
||||
existsInRadarr = true;
|
||||
if (ex.message.includes('404')) {
|
||||
logger.debug(
|
||||
`Movie [TMDB ID ${media.tmdbId}] not found in Radarr (404) - externalServiceId may be stale: ${externalServiceId}`,
|
||||
{
|
||||
label: 'Availability Sync',
|
||||
}
|
||||
);
|
||||
} else {
|
||||
logger.debug(
|
||||
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} movie [TMDB ID ${
|
||||
media.tmdbId
|
||||
}] from Radarr.`,
|
||||
{
|
||||
errorMessage: ex.message,
|
||||
externalServiceId: externalServiceId,
|
||||
label: 'Availability Sync',
|
||||
}
|
||||
);
|
||||
@@ -754,7 +760,6 @@ class AvailabilitySync {
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404')) {
|
||||
existsInSonarr = true;
|
||||
preventSeasonSearch = true;
|
||||
logger.debug(
|
||||
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} show [TMDB ID ${
|
||||
@@ -853,7 +858,18 @@ class AvailabilitySync {
|
||||
// We can use the cache we built when we fetched the series with mediaExistsInPlex
|
||||
try {
|
||||
let plexMedia: PlexMetadata | undefined;
|
||||
const currentRatingKey = is4k ? ratingKey4k : ratingKey;
|
||||
|
||||
if (!currentRatingKey) {
|
||||
logger.debug(
|
||||
`Skipping Plex check for ${is4k ? '4K' : 'non-4K'} ${
|
||||
media.mediaType === 'tv' ? 'show' : 'movie'
|
||||
} [TMDB ID ${media.tmdbId}] - no ratingKey available`,
|
||||
{
|
||||
label: 'Availability Sync',
|
||||
}
|
||||
);
|
||||
} else {
|
||||
if (ratingKey && !is4k) {
|
||||
plexMedia = await this.plexClient?.getMetadata(ratingKey);
|
||||
|
||||
@@ -874,10 +890,34 @@ class AvailabilitySync {
|
||||
|
||||
if (plexMedia) {
|
||||
existsInPlex = true;
|
||||
logger.debug(
|
||||
`Found ${is4k ? '4K' : 'non-4K'} ${
|
||||
media.mediaType === 'tv' ? 'show' : 'movie'
|
||||
} [TMDB ID ${media.tmdbId}] in Plex`,
|
||||
{
|
||||
ratingKey: is4k ? ratingKey4k : ratingKey,
|
||||
plexTitle: plexMedia.title,
|
||||
plexRatingKey: plexMedia.ratingKey,
|
||||
plexGuid: plexMedia.guid,
|
||||
label: 'Availability Sync',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404')) {
|
||||
existsInPlex = true;
|
||||
if (ex.message.includes('404')) {
|
||||
logger.debug(
|
||||
`Media ${is4k ? '4K' : 'non-4K'} ${
|
||||
media.mediaType === 'tv' ? 'show' : 'movie'
|
||||
} [TMDB ID ${
|
||||
media.tmdbId
|
||||
}] not found in Plex (404) - ratingKey may be stale`,
|
||||
{
|
||||
ratingKey: is4k ? ratingKey4k : ratingKey,
|
||||
label: 'Availability Sync',
|
||||
}
|
||||
);
|
||||
} else {
|
||||
preventSeasonSearch = true;
|
||||
logger.debug(
|
||||
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
|
||||
@@ -993,8 +1033,8 @@ class AvailabilitySync {
|
||||
existsInJellyfin = true;
|
||||
}
|
||||
} catch (ex) {
|
||||
if (!ex.message.includes('404' || '500')) {
|
||||
existsInJellyfin = false;
|
||||
if (!ex.message.includes('404') && !ex.message.includes('500')) {
|
||||
existsInJellyfin = true;
|
||||
preventSeasonSearch = true;
|
||||
logger.debug(
|
||||
`Failure retrieving the ${is4k ? '4K' : 'non-4K'} ${
|
||||
|
||||
@@ -45,9 +45,19 @@ class PushoverAgent
|
||||
}
|
||||
|
||||
public shouldSend(): boolean {
|
||||
const settings = this.getSettings();
|
||||
|
||||
if (
|
||||
settings.enabled &&
|
||||
settings.options.accessToken &&
|
||||
settings.options.userToken
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async getImagePayload(
|
||||
imageUrl: string
|
||||
): Promise<Partial<PushoverImagePayload>> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ interface ProcessOptions {
|
||||
is4k?: boolean;
|
||||
mediaAddedAt?: Date;
|
||||
ratingKey?: string;
|
||||
jellyfinMediaId?: string;
|
||||
imdbId?: string;
|
||||
serviceId?: number;
|
||||
externalServiceId?: number;
|
||||
externalServiceSlug?: string;
|
||||
@@ -95,6 +97,8 @@ class BaseScanner<T> {
|
||||
is4k = false,
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
jellyfinMediaId,
|
||||
imdbId,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
@@ -111,9 +115,11 @@ class BaseScanner<T> {
|
||||
let changedExisting = false;
|
||||
|
||||
if (existing[is4k ? 'status4k' : 'status'] !== MediaStatus.AVAILABLE) {
|
||||
existing[is4k ? 'status4k' : 'status'] = processing
|
||||
? MediaStatus.PROCESSING
|
||||
: MediaStatus.AVAILABLE;
|
||||
existing[is4k ? 'status4k' : 'status'] = !processing
|
||||
? MediaStatus.AVAILABLE
|
||||
: existing[is4k ? 'status4k' : 'status'] === MediaStatus.DELETED
|
||||
? MediaStatus.DELETED
|
||||
: MediaStatus.PROCESSING;
|
||||
if (mediaAddedAt) {
|
||||
existing.mediaAddedAt = mediaAddedAt;
|
||||
}
|
||||
@@ -133,6 +139,21 @@ class BaseScanner<T> {
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
jellyfinMediaId &&
|
||||
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] !==
|
||||
jellyfinMediaId
|
||||
) {
|
||||
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
|
||||
jellyfinMediaId;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (imdbId && !existing.imdbId) {
|
||||
existing.imdbId = imdbId;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
serviceId !== undefined &&
|
||||
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
|
||||
@@ -173,6 +194,7 @@ class BaseScanner<T> {
|
||||
} else {
|
||||
const newMedia = new Media();
|
||||
newMedia.tmdbId = tmdbId;
|
||||
newMedia.imdbId = imdbId;
|
||||
|
||||
newMedia.status =
|
||||
!is4k && !processing
|
||||
@@ -203,6 +225,13 @@ class BaseScanner<T> {
|
||||
newMedia.ratingKey4k =
|
||||
is4k && this.enable4kMovie ? ratingKey : undefined;
|
||||
}
|
||||
|
||||
if (jellyfinMediaId) {
|
||||
newMedia.jellyfinMediaId = !is4k ? jellyfinMediaId : undefined;
|
||||
newMedia.jellyfinMediaId4k =
|
||||
is4k && this.enable4kMovie ? jellyfinMediaId : undefined;
|
||||
}
|
||||
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved new media: ${title}`);
|
||||
}
|
||||
@@ -221,11 +250,12 @@ class BaseScanner<T> {
|
||||
*/
|
||||
protected async processShow(
|
||||
tmdbId: number,
|
||||
tvdbId: number,
|
||||
tvdbId: number | undefined,
|
||||
seasons: ProcessableSeason[],
|
||||
{
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
jellyfinMediaId,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
@@ -257,7 +287,7 @@ class BaseScanner<T> {
|
||||
(es) => es.seasonNumber === season.seasonNumber
|
||||
);
|
||||
|
||||
// We update the rating keys in the seasons loop because we need episode counts
|
||||
// We update the rating keys and jellyfinMediaId in the seasons loop because we need episode counts
|
||||
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
|
||||
media.ratingKey = ratingKey;
|
||||
}
|
||||
@@ -271,6 +301,23 @@ class BaseScanner<T> {
|
||||
media.ratingKey4k = ratingKey;
|
||||
}
|
||||
|
||||
if (
|
||||
media &&
|
||||
season.episodes > 0 &&
|
||||
media.jellyfinMediaId !== jellyfinMediaId
|
||||
) {
|
||||
media.jellyfinMediaId = jellyfinMediaId;
|
||||
}
|
||||
|
||||
if (
|
||||
media &&
|
||||
season.episodes4k > 0 &&
|
||||
this.enable4kShow &&
|
||||
media.jellyfinMediaId4k !== jellyfinMediaId
|
||||
) {
|
||||
media.jellyfinMediaId4k = jellyfinMediaId;
|
||||
}
|
||||
|
||||
if (existingSeason) {
|
||||
// Here we update seasons if they already exist.
|
||||
// If the season is already marked as available, we
|
||||
@@ -285,6 +332,11 @@ class BaseScanner<T> {
|
||||
season.processing &&
|
||||
existingSeason.status !== MediaStatus.DELETED
|
||||
? MediaStatus.PROCESSING
|
||||
: !season.is4kOverride &&
|
||||
!season.processing &&
|
||||
season.episodes === 0 &&
|
||||
existingSeason.status === MediaStatus.PROCESSING
|
||||
? MediaStatus.UNKNOWN
|
||||
: existingSeason.status;
|
||||
|
||||
// Same thing here, except we only do updates if 4k is enabled
|
||||
@@ -300,6 +352,11 @@ class BaseScanner<T> {
|
||||
season.processing &&
|
||||
existingSeason.status4k !== MediaStatus.DELETED
|
||||
? MediaStatus.PROCESSING
|
||||
: season.is4kOverride &&
|
||||
!season.processing &&
|
||||
season.episodes4k === 0 &&
|
||||
existingSeason.status4k === MediaStatus.PROCESSING
|
||||
? MediaStatus.UNKNOWN
|
||||
: existingSeason.status4k;
|
||||
} else {
|
||||
newSeasons.push(
|
||||
@@ -491,6 +548,22 @@ class BaseScanner<T> {
|
||||
)
|
||||
? ratingKey
|
||||
: undefined,
|
||||
jellyfinMediaId: newSeasons.some(
|
||||
(sn) =>
|
||||
sn.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
sn.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? jellyfinMediaId
|
||||
: undefined,
|
||||
jellyfinMediaId4k:
|
||||
this.enable4kShow &&
|
||||
newSeasons.some(
|
||||
(sn) =>
|
||||
sn.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
sn.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? jellyfinMediaId
|
||||
: undefined,
|
||||
status: isAllStandardSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: newSeasons.some(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import animeList from '@server/api/animelist';
|
||||
import type { JellyfinLibraryItem } from '@server/api/jellyfin';
|
||||
import type {
|
||||
JellyfinLibraryItem,
|
||||
JellyfinLibraryItemExtended,
|
||||
} from '@server/api/jellyfin';
|
||||
import JellyfinAPI from '@server/api/jellyfin';
|
||||
import { getMetadataProvider } from '@server/api/metadata';
|
||||
import TheMovieDb from '@server/api/themoviedb';
|
||||
@@ -8,96 +11,73 @@ import type {
|
||||
TmdbKeyword,
|
||||
TmdbTvDetails,
|
||||
} from '@server/api/themoviedb/interfaces';
|
||||
import { MediaStatus, MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import Season from '@server/entity/Season';
|
||||
import { User } from '@server/entity/User';
|
||||
import type {
|
||||
ProcessableSeason,
|
||||
RunnableScanner,
|
||||
StatusBase,
|
||||
} from '@server/lib/scanners/baseScanner';
|
||||
import BaseScanner from '@server/lib/scanners/baseScanner';
|
||||
import type { Library } from '@server/lib/settings';
|
||||
import { getSettings } from '@server/lib/settings';
|
||||
import logger from '@server/logger';
|
||||
import AsyncLock from '@server/utils/asyncLock';
|
||||
import { getHostname } from '@server/utils/getHostname';
|
||||
import { randomUUID as uuid } from 'crypto';
|
||||
import { uniqWith } from 'lodash';
|
||||
|
||||
const BUNDLE_SIZE = 20;
|
||||
const UPDATE_RATE = 4 * 1000;
|
||||
|
||||
interface SyncStatus {
|
||||
running: boolean;
|
||||
progress: number;
|
||||
total: number;
|
||||
interface JellyfinSyncStatus extends StatusBase {
|
||||
currentLibrary: Library;
|
||||
libraries: Library[];
|
||||
}
|
||||
|
||||
class JellyfinScanner {
|
||||
private sessionId: string;
|
||||
private tmdb: TheMovieDb;
|
||||
class JellyfinScanner
|
||||
extends BaseScanner<JellyfinLibraryItem>
|
||||
implements RunnableScanner<JellyfinSyncStatus>
|
||||
{
|
||||
private jfClient: JellyfinAPI;
|
||||
private items: JellyfinLibraryItem[] = [];
|
||||
private progress = 0;
|
||||
private libraries: Library[];
|
||||
private currentLibrary: Library;
|
||||
private running = false;
|
||||
private isRecentOnly = false;
|
||||
private enable4kMovie = false;
|
||||
private enable4kShow = false;
|
||||
private asyncLock = new AsyncLock();
|
||||
private processedAnidbSeason: Map<number, Map<number, number>>;
|
||||
|
||||
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
|
||||
this.tmdb = new TheMovieDb();
|
||||
|
||||
super('Jellyfin Sync');
|
||||
this.isRecentOnly = isRecentOnly ?? false;
|
||||
}
|
||||
|
||||
private async getExisting(tmdbId: number, mediaType: MediaType) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
const existing = await mediaRepository.findOne({
|
||||
where: { tmdbId: tmdbId, mediaType },
|
||||
});
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
private async processMovie(jellyfinitem: JellyfinLibraryItem) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
try {
|
||||
private async extractMovieIds(jellyfinitem: JellyfinLibraryItem): Promise<{
|
||||
tmdbId: number;
|
||||
imdbId?: string;
|
||||
metadata: JellyfinLibraryItemExtended;
|
||||
} | null> {
|
||||
let metadata = await this.jfClient.getItemData(jellyfinitem.Id);
|
||||
const newMedia = new Media();
|
||||
|
||||
if (!metadata?.Id) {
|
||||
logger.debug('No Id metadata for this title. Skipping', {
|
||||
label: 'Jellyfin Sync',
|
||||
this.log('No Id metadata for this title. Skipping', 'debug', {
|
||||
jellyfinItemId: jellyfinitem.Id,
|
||||
});
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
const anidbId = Number(metadata.ProviderIds.AniDB ?? null);
|
||||
|
||||
newMedia.tmdbId = Number(metadata.ProviderIds.Tmdb ?? null);
|
||||
newMedia.imdbId = metadata.ProviderIds.Imdb;
|
||||
let tmdbId = Number(metadata.ProviderIds.Tmdb ?? null);
|
||||
let imdbId = metadata.ProviderIds.Imdb;
|
||||
|
||||
// We use anidb only if we have the anidbId and nothing else
|
||||
if (anidbId && !newMedia.imdbId && !newMedia.tmdbId) {
|
||||
if (anidbId && !imdbId && !tmdbId) {
|
||||
const result = animeList.getFromAnidbId(anidbId);
|
||||
newMedia.tmdbId = Number(result?.tmdbId ?? null);
|
||||
newMedia.imdbId = result?.imdbId;
|
||||
tmdbId = Number(result?.tmdbId ?? null);
|
||||
imdbId = result?.imdbId;
|
||||
}
|
||||
|
||||
if (newMedia.imdbId && !isNaN(newMedia.tmdbId)) {
|
||||
if (imdbId && !tmdbId) {
|
||||
const tmdbMovie = await this.tmdb.getMediaByImdbId({
|
||||
imdbId: newMedia.imdbId,
|
||||
imdbId: imdbId,
|
||||
});
|
||||
newMedia.tmdbId = tmdbMovie.id;
|
||||
tmdbId = tmdbMovie.id;
|
||||
}
|
||||
if (!newMedia.tmdbId) {
|
||||
|
||||
if (!tmdbId) {
|
||||
throw new Error('Unable to find TMDb ID');
|
||||
}
|
||||
|
||||
@@ -114,7 +94,7 @@ class JellyfinScanner {
|
||||
this.log('No season found for anidb movie', 'debug', {
|
||||
jellyfinitem,
|
||||
});
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
const episodes = await this.jfClient.getEpisodes(
|
||||
jellyfinitem.Id,
|
||||
@@ -124,17 +104,27 @@ class JellyfinScanner {
|
||||
this.log('No episode found for anidb movie', 'debug', {
|
||||
jellyfinitem,
|
||||
});
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
metadata = await this.jfClient.getItemData(episodes[0].Id);
|
||||
if (!metadata) {
|
||||
this.log('No metadata found for anidb movie', 'debug', {
|
||||
jellyfinitem,
|
||||
});
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return { tmdbId, imdbId, metadata };
|
||||
}
|
||||
|
||||
private async processJellyfinMovie(jellyfinitem: JellyfinLibraryItem) {
|
||||
try {
|
||||
const extracted = await this.extractMovieIds(jellyfinitem);
|
||||
if (!extracted) return;
|
||||
|
||||
const { tmdbId, imdbId, metadata } = extracted;
|
||||
|
||||
const has4k = metadata.MediaSources?.some((MediaSource) => {
|
||||
return MediaSource.MediaStreams.filter(
|
||||
(MediaStream) => MediaStream.Type === 'Video'
|
||||
@@ -151,93 +141,29 @@ class JellyfinScanner {
|
||||
});
|
||||
});
|
||||
|
||||
await this.asyncLock.dispatch(newMedia.tmdbId, async () => {
|
||||
if (!metadata) {
|
||||
// this will never execute, but typescript thinks somebody could reset tvShow from
|
||||
// outer scope back to null before this async gets called
|
||||
return;
|
||||
}
|
||||
const mediaAddedAt = metadata.DateCreated
|
||||
? new Date(metadata.DateCreated)
|
||||
: undefined;
|
||||
|
||||
const existing = await this.getExisting(
|
||||
newMedia.tmdbId,
|
||||
MediaType.MOVIE
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
let changedExisting = false;
|
||||
|
||||
if (
|
||||
(hasOtherResolution || (!this.enable4kMovie && has4k)) &&
|
||||
existing.status !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
existing.status = MediaStatus.AVAILABLE;
|
||||
existing.mediaAddedAt = new Date(metadata.DateCreated ?? '');
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
has4k &&
|
||||
this.enable4kMovie &&
|
||||
existing.status4k !== MediaStatus.AVAILABLE
|
||||
) {
|
||||
existing.status4k = MediaStatus.AVAILABLE;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (!existing.mediaAddedAt && !changedExisting) {
|
||||
existing.mediaAddedAt = new Date(metadata.DateCreated ?? '');
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
(hasOtherResolution || (has4k && !this.enable4kMovie)) &&
|
||||
existing.jellyfinMediaId !== metadata.Id
|
||||
) {
|
||||
existing.jellyfinMediaId = metadata.Id;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
has4k &&
|
||||
this.enable4kMovie &&
|
||||
existing.jellyfinMediaId4k !== metadata.Id
|
||||
) {
|
||||
existing.jellyfinMediaId4k = metadata.Id;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (changedExisting) {
|
||||
await mediaRepository.save(existing);
|
||||
this.log(
|
||||
`Request for ${metadata.Name} exists. New media types set to AVAILABLE`,
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
this.log(
|
||||
`Title already exists and no new media types found ${metadata.Name}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
newMedia.status =
|
||||
hasOtherResolution || (!this.enable4kMovie && has4k)
|
||||
? MediaStatus.AVAILABLE
|
||||
: MediaStatus.UNKNOWN;
|
||||
newMedia.status4k =
|
||||
has4k && this.enable4kMovie
|
||||
? MediaStatus.AVAILABLE
|
||||
: MediaStatus.UNKNOWN;
|
||||
newMedia.mediaType = MediaType.MOVIE;
|
||||
newMedia.mediaAddedAt = new Date(metadata.DateCreated ?? '');
|
||||
newMedia.jellyfinMediaId =
|
||||
hasOtherResolution || (!this.enable4kMovie && has4k)
|
||||
? metadata.Id
|
||||
: null;
|
||||
newMedia.jellyfinMediaId4k =
|
||||
has4k && this.enable4kMovie ? metadata.Id : null;
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${metadata.Name}`);
|
||||
}
|
||||
if (hasOtherResolution || (!this.enable4kMovie && has4k)) {
|
||||
await this.processMovie(tmdbId, {
|
||||
is4k: false,
|
||||
mediaAddedAt,
|
||||
jellyfinMediaId: metadata.Id,
|
||||
imdbId,
|
||||
title: metadata.Name,
|
||||
});
|
||||
}
|
||||
|
||||
if (has4k && this.enable4kMovie) {
|
||||
await this.processMovie(tmdbId, {
|
||||
is4k: true,
|
||||
mediaAddedAt,
|
||||
jellyfinMediaId: metadata.Id,
|
||||
imdbId,
|
||||
title: metadata.Name,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`Failed to process Jellyfin item, id: ${jellyfinitem.Id}`,
|
||||
@@ -286,9 +212,7 @@ class JellyfinScanner {
|
||||
return tvShow;
|
||||
}
|
||||
|
||||
private async processShow(jellyfinitem: JellyfinLibraryItem) {
|
||||
const mediaRepository = getRepository(Media);
|
||||
|
||||
private async processJellyfinShow(jellyfinitem: JellyfinLibraryItem) {
|
||||
let tvShow: TmdbTvDetails | null = null;
|
||||
|
||||
try {
|
||||
@@ -297,8 +221,7 @@ class JellyfinScanner {
|
||||
const metadata = await this.jfClient.getItemData(Id);
|
||||
|
||||
if (!metadata?.Id) {
|
||||
logger.debug('No Id metadata for this title. Skipping', {
|
||||
label: 'Jellyfin Sync',
|
||||
this.log('No Id metadata for this title. Skipping', 'debug', {
|
||||
jellyfinItemId: jellyfinitem.Id,
|
||||
});
|
||||
return;
|
||||
@@ -315,6 +238,7 @@ class JellyfinScanner {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!tvShow && metadata.ProviderIds.Tvdb) {
|
||||
try {
|
||||
tvShow = await this.getTvShow({
|
||||
@@ -326,6 +250,7 @@ class JellyfinScanner {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let tvdbSeasonFromAnidb: number | undefined;
|
||||
if (!tvShow && metadata.ProviderIds.AniDB) {
|
||||
const anidbId = Number(metadata.ProviderIds.AniDB);
|
||||
@@ -344,39 +269,23 @@ class JellyfinScanner {
|
||||
}
|
||||
// With AniDB we can have mixed libraries with movies in a "show" library
|
||||
else if (result?.imdbId || result?.tmdbId) {
|
||||
await this.processMovie(jellyfinitem);
|
||||
await this.processJellyfinMovie(jellyfinitem);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (tvShow) {
|
||||
await this.asyncLock.dispatch(tvShow.id, async () => {
|
||||
if (!tvShow) {
|
||||
// this will never execute, but typescript thinks somebody could reset tvShow from
|
||||
// outer scope back to null before this async gets called
|
||||
return;
|
||||
}
|
||||
|
||||
// Lets get the available seasons from Jellyfin
|
||||
const seasons = tvShow.seasons;
|
||||
const media = await this.getExisting(tvShow.id, MediaType.TV);
|
||||
|
||||
const newSeasons: Season[] = [];
|
||||
|
||||
const currentStandardSeasonAvailable = (
|
||||
media?.seasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
const current4kSeasonAvailable = (
|
||||
media?.seasons.filter(
|
||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
const jellyfinSeasons = await this.jfClient.getSeasons(Id);
|
||||
|
||||
for (const season of seasons) {
|
||||
const processableSeasons: ProcessableSeason[] = [];
|
||||
|
||||
const settings = getSettings();
|
||||
const filteredSeasons = settings.main.enableSpecialEpisodes
|
||||
? seasons
|
||||
: seasons.filter((sn) => sn.season_number !== 0);
|
||||
|
||||
for (const season of filteredSeasons) {
|
||||
const matchedJellyfinSeason = jellyfinSeasons.find((md) => {
|
||||
if (tvdbSeasonFromAnidb) {
|
||||
// In AniDB we don't have the concept of seasons,
|
||||
@@ -392,10 +301,6 @@ class JellyfinScanner {
|
||||
}
|
||||
});
|
||||
|
||||
const existingSeason = media?.seasons.find(
|
||||
(es) => es.seasonNumber === season.season_number
|
||||
);
|
||||
|
||||
// Check if we found the matching season and it has all the available episodes
|
||||
if (matchedJellyfinSeason) {
|
||||
let totalStandard = 0;
|
||||
@@ -441,233 +346,72 @@ class JellyfinScanner {
|
||||
episode.IndexNumberEnd - episode.IndexNumber + 1;
|
||||
}
|
||||
|
||||
// MediaSources field is included in response when includeMediaInfo is true
|
||||
// We iterate all MediaSources to detect if episode has both standard AND 4K versions
|
||||
episode.MediaSources?.some((MediaSource) => {
|
||||
return MediaSource.MediaStreams.some((MediaStream) => {
|
||||
if (MediaStream.Type === 'Video') {
|
||||
if ((MediaStream.Width ?? 0) >= 2000) {
|
||||
total4k += episodeCount;
|
||||
} else {
|
||||
totalStandard += episodeCount;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
const has4k = episode.MediaSources?.some((MediaSource) =>
|
||||
MediaSource.MediaStreams.some(
|
||||
(MediaStream) =>
|
||||
MediaStream.Type === 'Video' &&
|
||||
(MediaStream.Width ?? 0) > 2000
|
||||
)
|
||||
);
|
||||
|
||||
const hasStandard = episode.MediaSources?.some((MediaSource) =>
|
||||
MediaSource.MediaStreams.some(
|
||||
(MediaStream) =>
|
||||
MediaStream.Type === 'Video' &&
|
||||
(MediaStream.Width ?? 0) <= 2000
|
||||
)
|
||||
);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// With AniDB we can have multiple shows for one season, so we need to save
|
||||
// the episode from all the jellyfin entries to get the total
|
||||
if (tvdbSeasonFromAnidb) {
|
||||
if (this.processedAnidbSeason.has(tvShow.id)) {
|
||||
const show = this.processedAnidbSeason.get(tvShow.id)!;
|
||||
if (show.has(season.season_number)) {
|
||||
show.set(
|
||||
season.season_number,
|
||||
show.get(season.season_number)! + totalStandard
|
||||
);
|
||||
let show = this.processedAnidbSeason.get(tvShow.id);
|
||||
|
||||
totalStandard = show.get(season.season_number)!;
|
||||
if (!show) {
|
||||
show = new Map([[season.season_number, totalStandard]]);
|
||||
this.processedAnidbSeason.set(tvShow.id, show);
|
||||
} else {
|
||||
show.set(season.season_number, totalStandard);
|
||||
}
|
||||
} else {
|
||||
this.processedAnidbSeason.set(
|
||||
tvShow.id,
|
||||
new Map([[season.season_number, totalStandard]])
|
||||
);
|
||||
const currentCount = show.get(season.season_number) ?? 0;
|
||||
const newCount = currentCount + totalStandard;
|
||||
show.set(season.season_number, newCount);
|
||||
totalStandard = newCount;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
media &&
|
||||
(totalStandard > 0 || (total4k > 0 && !this.enable4kShow)) &&
|
||||
media.jellyfinMediaId !== Id
|
||||
) {
|
||||
media.jellyfinMediaId = Id;
|
||||
}
|
||||
|
||||
if (
|
||||
media &&
|
||||
total4k > 0 &&
|
||||
this.enable4kShow &&
|
||||
media.jellyfinMediaId4k !== Id
|
||||
) {
|
||||
media.jellyfinMediaId4k = Id;
|
||||
}
|
||||
|
||||
if (existingSeason) {
|
||||
// These ternary statements look super confusing, but they are simply
|
||||
// setting the status to AVAILABLE if all of a type is there, partially if some,
|
||||
// and then not modifying the status if there are 0 items
|
||||
existingSeason.status =
|
||||
totalStandard >= season.episode_count ||
|
||||
existingSeason.status === MediaStatus.AVAILABLE
|
||||
? MediaStatus.AVAILABLE
|
||||
: totalStandard > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: existingSeason.status;
|
||||
existingSeason.status4k =
|
||||
(this.enable4kShow && total4k >= season.episode_count) ||
|
||||
existingSeason.status4k === MediaStatus.AVAILABLE
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow && total4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: existingSeason.status4k;
|
||||
} else {
|
||||
newSeasons.push(
|
||||
new Season({
|
||||
processableSeasons.push({
|
||||
seasonNumber: season.season_number,
|
||||
// This ternary is the same as the ones above, but it just falls back to "UNKNOWN"
|
||||
// if we dont have any items for the season
|
||||
status:
|
||||
totalStandard >= season.episode_count
|
||||
? MediaStatus.AVAILABLE
|
||||
: totalStandard > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
this.enable4kShow && total4k >= season.episode_count
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow && total4k > 0
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove extras season. We dont count it for determining availability
|
||||
const filteredSeasons = tvShow.seasons.filter(
|
||||
(season) => season.season_number !== 0
|
||||
);
|
||||
|
||||
const isAllStandardSeasons =
|
||||
newSeasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
).length +
|
||||
(media?.seasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
).length ?? 0) >=
|
||||
filteredSeasons.length;
|
||||
|
||||
const isAll4kSeasons =
|
||||
newSeasons.filter(
|
||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||
).length +
|
||||
(media?.seasons.filter(
|
||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||
).length ?? 0) >=
|
||||
filteredSeasons.length;
|
||||
|
||||
if (media) {
|
||||
// Update existing
|
||||
media.seasons = [...media.seasons, ...newSeasons];
|
||||
|
||||
const newStandardSeasonAvailable = (
|
||||
media.seasons.filter(
|
||||
(season) => season.status === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
const new4kSeasonAvailable = (
|
||||
media.seasons.filter(
|
||||
(season) => season.status4k === MediaStatus.AVAILABLE
|
||||
) ?? []
|
||||
).length;
|
||||
|
||||
// If at least one new season has become available, update
|
||||
// the lastSeasonChange field so we can trigger notifications
|
||||
if (newStandardSeasonAvailable > currentStandardSeasonAvailable) {
|
||||
this.log(
|
||||
`Detected ${
|
||||
newStandardSeasonAvailable - currentStandardSeasonAvailable
|
||||
} new standard season(s) for ${tvShow.name}`,
|
||||
'debug'
|
||||
);
|
||||
media.lastSeasonChange = new Date();
|
||||
media.mediaAddedAt = new Date(metadata.DateCreated ?? '');
|
||||
}
|
||||
|
||||
if (new4kSeasonAvailable > current4kSeasonAvailable) {
|
||||
this.log(
|
||||
`Detected ${
|
||||
new4kSeasonAvailable - current4kSeasonAvailable
|
||||
} new 4K season(s) for ${tvShow.name}`,
|
||||
'debug'
|
||||
);
|
||||
media.lastSeasonChange = new Date();
|
||||
}
|
||||
|
||||
if (!media.mediaAddedAt) {
|
||||
media.mediaAddedAt = new Date(metadata.DateCreated ?? '');
|
||||
}
|
||||
|
||||
// If the show is already available, and there are no new seasons, dont adjust
|
||||
// the status
|
||||
const shouldStayAvailable =
|
||||
media.status === MediaStatus.AVAILABLE &&
|
||||
newSeasons.filter(
|
||||
(season) => season.status !== MediaStatus.UNKNOWN
|
||||
).length === 0;
|
||||
const shouldStayAvailable4k =
|
||||
media.status4k === MediaStatus.AVAILABLE &&
|
||||
newSeasons.filter(
|
||||
(season) => season.status4k !== MediaStatus.UNKNOWN
|
||||
).length === 0;
|
||||
|
||||
media.status =
|
||||
isAllStandardSeasons || shouldStayAvailable
|
||||
? MediaStatus.AVAILABLE
|
||||
: media.seasons.some(
|
||||
(season) => season.status !== MediaStatus.UNKNOWN
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN;
|
||||
media.status4k =
|
||||
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow &&
|
||||
media.seasons.some(
|
||||
(season) => season.status4k !== MediaStatus.UNKNOWN
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN;
|
||||
await mediaRepository.save(media);
|
||||
this.log(`Updating existing title: ${tvShow.name}`);
|
||||
} else {
|
||||
const newMedia = new Media({
|
||||
mediaType: MediaType.TV,
|
||||
seasons: newSeasons,
|
||||
tmdbId: tvShow.id,
|
||||
tvdbId: tvShow.external_ids.tvdb_id,
|
||||
mediaAddedAt: new Date(metadata.DateCreated ?? ''),
|
||||
jellyfinMediaId: isAllStandardSeasons ? Id : null,
|
||||
jellyfinMediaId4k:
|
||||
isAll4kSeasons && this.enable4kShow ? Id : null,
|
||||
status: isAllStandardSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: newSeasons.some(
|
||||
(season) => season.status !== MediaStatus.UNKNOWN
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN,
|
||||
status4k:
|
||||
isAll4kSeasons && this.enable4kShow
|
||||
? MediaStatus.AVAILABLE
|
||||
: this.enable4kShow &&
|
||||
newSeasons.some(
|
||||
(season) => season.status4k !== MediaStatus.UNKNOWN
|
||||
)
|
||||
? MediaStatus.PARTIALLY_AVAILABLE
|
||||
: MediaStatus.UNKNOWN,
|
||||
totalEpisodes: season.episode_count,
|
||||
episodes: totalStandard,
|
||||
episodes4k: total4k,
|
||||
});
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved ${tvShow.name}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await this.processShow(
|
||||
tvShow.id,
|
||||
tvShow.external_ids?.tvdb_id,
|
||||
processableSeasons,
|
||||
{
|
||||
mediaAddedAt: metadata.DateCreated
|
||||
? new Date(metadata.DateCreated)
|
||||
: undefined,
|
||||
jellyfinMediaId: Id,
|
||||
title: tvShow.name,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
this.log(
|
||||
`No information found for the show: ${metadata.Name}`,
|
||||
@@ -683,70 +427,17 @@ class JellyfinScanner {
|
||||
jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id
|
||||
}`,
|
||||
'error',
|
||||
{
|
||||
errorMessage: e.message,
|
||||
jellyfinitem,
|
||||
}
|
||||
{ errorMessage: e.message, jellyfinitem }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async processItems(slicedItems: JellyfinLibraryItem[]) {
|
||||
this.processedAnidbSeason = new Map();
|
||||
await Promise.all(
|
||||
slicedItems.map(async (item) => {
|
||||
private async processItem(item: JellyfinLibraryItem): Promise<void> {
|
||||
if (item.Type === 'Movie') {
|
||||
await this.processMovie(item);
|
||||
await this.processJellyfinMovie(item);
|
||||
} else if (item.Type === 'Series') {
|
||||
await this.processShow(item);
|
||||
await this.processJellyfinShow(item);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private async loop({
|
||||
start = 0,
|
||||
end = BUNDLE_SIZE,
|
||||
sessionId,
|
||||
}: {
|
||||
start?: number;
|
||||
end?: number;
|
||||
sessionId?: string;
|
||||
} = {}) {
|
||||
const slicedItems = this.items.slice(start, end);
|
||||
|
||||
if (!this.running) {
|
||||
throw new Error('Sync was aborted.');
|
||||
}
|
||||
|
||||
if (this.sessionId !== sessionId) {
|
||||
throw new Error('New session was started. Old session aborted.');
|
||||
}
|
||||
|
||||
if (start < this.items.length) {
|
||||
this.progress = start;
|
||||
await this.processItems(slicedItems);
|
||||
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
setTimeout(() => {
|
||||
this.loop({
|
||||
start: start + BUNDLE_SIZE,
|
||||
end: end + BUNDLE_SIZE,
|
||||
sessionId,
|
||||
})
|
||||
.then(() => resolve())
|
||||
.catch((e) => reject(new Error(e.message)));
|
||||
}, UPDATE_RATE)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private log(
|
||||
message: string,
|
||||
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
|
||||
optional?: Record<string, unknown>
|
||||
): void {
|
||||
logger[level](message, { label: 'Jellyfin Sync', ...optional });
|
||||
}
|
||||
|
||||
public async run(): Promise<void> {
|
||||
@@ -759,14 +450,9 @@ class JellyfinScanner {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = uuid();
|
||||
this.sessionId = sessionId;
|
||||
logger.info('Jellyfin Sync Starting', {
|
||||
sessionId,
|
||||
label: 'Jellyfin Sync',
|
||||
});
|
||||
const sessionId = this.startRun();
|
||||
|
||||
try {
|
||||
this.running = true;
|
||||
const userRepository = getRepository(User);
|
||||
const admin = await userRepository.findOne({
|
||||
where: { id: 1 },
|
||||
@@ -792,25 +478,11 @@ class JellyfinScanner {
|
||||
|
||||
await animeList.sync();
|
||||
|
||||
this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
|
||||
if (this.enable4kMovie) {
|
||||
this.log(
|
||||
'At least one 4K Radarr server was detected. 4K movie detection is now enabled',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k);
|
||||
if (this.enable4kShow) {
|
||||
this.log(
|
||||
'At least one 4K Sonarr server was detected. 4K series detection is now enabled',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
if (this.isRecentOnly) {
|
||||
for (const library of this.libraries) {
|
||||
this.currentLibrary = library;
|
||||
// Reset AniDB season tracking per library
|
||||
this.processedAnidbSeason = new Map();
|
||||
this.log(
|
||||
`Beginning to process recently added for library: ${library.name}`,
|
||||
'info'
|
||||
@@ -830,16 +502,19 @@ class JellyfinScanner {
|
||||
return mediaA.Id === mediaB.Id;
|
||||
});
|
||||
|
||||
await this.loop({ sessionId });
|
||||
await this.loop(this.processItem.bind(this), { sessionId });
|
||||
}
|
||||
} else {
|
||||
for (const library of this.libraries) {
|
||||
this.currentLibrary = library;
|
||||
// Reset AniDB season tracking per library
|
||||
this.processedAnidbSeason = new Map();
|
||||
this.log(`Beginning to process library: ${library.name}`, 'info');
|
||||
this.items = await this.jfClient.getLibraryContents(library.id);
|
||||
await this.loop({ sessionId });
|
||||
await this.loop(this.processItem.bind(this), { sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
this.log(
|
||||
this.isRecentOnly
|
||||
? 'Recently Added Scan Complete'
|
||||
@@ -847,19 +522,13 @@ class JellyfinScanner {
|
||||
'info'
|
||||
);
|
||||
} catch (e) {
|
||||
logger.error('Sync interrupted', {
|
||||
label: 'Jellyfin Sync',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
this.log('Sync interrupted', 'error', { errorMessage: e.message });
|
||||
} finally {
|
||||
// If a new scanning session hasnt started, set running back to false
|
||||
if (this.sessionId === sessionId) {
|
||||
this.running = false;
|
||||
}
|
||||
this.endRun(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
public status(): SyncStatus {
|
||||
public status(): JellyfinSyncStatus {
|
||||
return {
|
||||
running: this.running,
|
||||
progress: this.progress,
|
||||
@@ -868,10 +537,6 @@ class JellyfinScanner {
|
||||
libraries: this.libraries,
|
||||
};
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const jellyfinFullScanner = new JellyfinScanner();
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -626,76 +626,6 @@ authRoutes.post('/local', async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
const mainUser = await userRepository.findOneOrFail({
|
||||
select: { id: true, plexToken: true, plexId: true },
|
||||
where: { id: 1 },
|
||||
});
|
||||
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
|
||||
|
||||
if (!user.plexId) {
|
||||
try {
|
||||
const plexUsersResponse = await mainPlexTv.getUsers();
|
||||
const account = plexUsersResponse.MediaContainer.User.find(
|
||||
(account) =>
|
||||
account.$.email &&
|
||||
account.$.email.toLowerCase() === user.email.toLowerCase()
|
||||
)?.$;
|
||||
|
||||
if (
|
||||
account &&
|
||||
(await mainPlexTv.checkUserAccess(parseInt(account.id)))
|
||||
) {
|
||||
logger.info(
|
||||
'Found matching Plex user; updating user with Plex data',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
email: body.email,
|
||||
userId: user.id,
|
||||
plexId: account.id,
|
||||
plexUsername: account.username,
|
||||
}
|
||||
);
|
||||
|
||||
user.plexId = parseInt(account.id);
|
||||
user.avatar = account.thumb;
|
||||
user.email = account.email;
|
||||
user.plexUsername = account.username;
|
||||
user.userType = UserType.PLEX;
|
||||
|
||||
await userRepository.save(user);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong fetching Plex users', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
user.plexId &&
|
||||
user.plexId !== mainUser.plexId &&
|
||||
!(await mainPlexTv.checkUserAccess(user.plexId))
|
||||
) {
|
||||
logger.warn(
|
||||
'Failed sign-in attempt from Plex user without access to the media server',
|
||||
{
|
||||
label: 'API',
|
||||
account: {
|
||||
ip: req.ip,
|
||||
email: body.email,
|
||||
userId: user.id,
|
||||
plexId: user.plexId,
|
||||
},
|
||||
}
|
||||
);
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Access denied.',
|
||||
});
|
||||
}
|
||||
|
||||
// Set logged in session
|
||||
if (user && req.session) {
|
||||
req.session.userId = user.id;
|
||||
@@ -775,7 +705,7 @@ authRoutes.post('/logout', async (req, res, next) => {
|
||||
});
|
||||
return next({ status: 500, message: 'Failed to destroy session.' });
|
||||
}
|
||||
logger.info('Successfully logged out user', {
|
||||
logger.debug('Successfully logged out user', {
|
||||
label: 'Auth',
|
||||
userId,
|
||||
});
|
||||
|
||||
@@ -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,19 +189,69 @@ 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({
|
||||
// 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 } },
|
||||
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) {
|
||||
// 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;
|
||||
|
||||
await transactionalRepo.save(existingSubscription);
|
||||
|
||||
logger.debug(
|
||||
'User push subscription already exists. Skipping registration.',
|
||||
'Updated existing push subscription with new keys for same endpoint.',
|
||||
{ label: 'API' }
|
||||
);
|
||||
return res.status(204).send();
|
||||
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({
|
||||
@@ -211,7 +262,9 @@ router.post<
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
userPushSubRepository.save(userPushSubscription);
|
||||
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) {
|
||||
|
||||
@@ -11,9 +11,14 @@ export let requestInterceptorFunction: (
|
||||
) => InternalAxiosRequestConfig;
|
||||
|
||||
export default async function createCustomProxyAgent(
|
||||
proxySettings: ProxySettings
|
||||
proxySettings: ProxySettings,
|
||||
forceIpv4First?: boolean
|
||||
) {
|
||||
const defaultAgent = new Agent({ keepAliveTimeout: 5000 });
|
||||
const defaultAgent = new Agent({
|
||||
keepAliveTimeout: 5000,
|
||||
connections: 50,
|
||||
connect: forceIpv4First ? { family: 4 } : undefined,
|
||||
});
|
||||
|
||||
const skipUrl = (url: string | URL) => {
|
||||
const hostname =
|
||||
@@ -67,16 +72,23 @@ export default async function createCustomProxyAgent(
|
||||
uri: proxyUrl,
|
||||
token,
|
||||
keepAliveTimeout: 5000,
|
||||
connections: 50,
|
||||
connect: forceIpv4First ? { family: 4 } : undefined,
|
||||
});
|
||||
|
||||
setGlobalDispatcher(proxyAgent.compose(noProxyInterceptor));
|
||||
|
||||
axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl, {
|
||||
const agentOptions = {
|
||||
headers: token ? { 'proxy-authorization': token } : undefined,
|
||||
});
|
||||
axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl, {
|
||||
headers: token ? { 'proxy-authorization': token } : undefined,
|
||||
});
|
||||
keepAlive: true,
|
||||
maxSockets: 50,
|
||||
maxFreeSockets: 10,
|
||||
timeout: 5000,
|
||||
scheduling: 'lifo' as const,
|
||||
family: forceIpv4First ? 4 : undefined,
|
||||
};
|
||||
axios.defaults.httpAgent = new HttpProxyAgent(proxyUrl, agentOptions);
|
||||
axios.defaults.httpsAgent = new HttpsProxyAgent(proxyUrl, agentOptions);
|
||||
|
||||
requestInterceptorFunction = (config) => {
|
||||
const url = config.baseURL
|
||||
|
||||
@@ -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,7 +242,8 @@ const ManageSlideOver = ({
|
||||
</h3>
|
||||
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
||||
{filterDuplicateDownloads(data.mediaInfo?.downloadStatus).map(
|
||||
(status, index) => (
|
||||
<Tooltip
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
content={status.title}
|
||||
@@ -239,17 +252,20 @@ const ManageSlideOver = ({
|
||||
<DownloadBlock downloadItem={status} />
|
||||
</li>
|
||||
</Tooltip>
|
||||
))}
|
||||
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
||||
)
|
||||
)}
|
||||
{filterDuplicateDownloads(data.mediaInfo?.downloadStatus4k).map(
|
||||
(status, index) => (
|
||||
<Tooltip
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
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>
|
||||
|
||||
@@ -5,7 +5,6 @@ import Tooltip from '@app/components/Common/Tooltip';
|
||||
import RequestModal from '@app/components/RequestModal';
|
||||
import StatusBadge from '@app/components/StatusBadge';
|
||||
import useDeepLinks from '@app/hooks/useDeepLinks';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
@@ -219,7 +218,6 @@ interface RequestCardProps {
|
||||
}
|
||||
|
||||
const RequestCard = ({ request, onTitleData }: RequestCardProps) => {
|
||||
const settings = useSettings();
|
||||
const { ref, inView } = useInView({
|
||||
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">
|
||||
<span className="mr-2 font-bold ">
|
||||
{intl.formatMessage(messages.seasons, {
|
||||
seasonCount:
|
||||
(settings.currentSettings.enableSpecialEpisodes
|
||||
? title.seasons.length
|
||||
: title.seasons.filter(
|
||||
(season) => season.seasonNumber !== 0
|
||||
).length) === request.seasons.length
|
||||
? 0
|
||||
: request.seasons.length,
|
||||
seasonCount: request.seasons.length,
|
||||
})}
|
||||
</span>
|
||||
<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 StatusBadge from '@app/components/StatusBadge';
|
||||
import useDeepLinks from '@app/hooks/useDeepLinks';
|
||||
import useSettings from '@app/hooks/useSettings';
|
||||
import { Permission, useUser } from '@app/hooks/useUser';
|
||||
import globalMessages from '@app/i18n/globalMessages';
|
||||
import defineMessages from '@app/utils/defineMessages';
|
||||
@@ -295,7 +294,6 @@ interface RequestItemProps {
|
||||
}
|
||||
|
||||
const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
const settings = useSettings();
|
||||
const { ref, inView } = useInView({
|
||||
triggerOnce: true,
|
||||
});
|
||||
@@ -470,14 +468,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
|
||||
<div className="card-field">
|
||||
<span className="card-field-name">
|
||||
{intl.formatMessage(messages.seasons, {
|
||||
seasonCount:
|
||||
(settings.currentSettings.enableSpecialEpisodes
|
||||
? title.seasons.length
|
||||
: title.seasons.filter(
|
||||
(season) => season.seasonNumber !== 0
|
||||
).length) === request.seasons.length
|
||||
? 0
|
||||
: request.seasons.length,
|
||||
seasonCount: request.seasons.length,
|
||||
})}
|
||||
</span>
|
||||
<div className="hide-scrollbar flex flex-nowrap overflow-x-scroll">
|
||||
|
||||
@@ -49,7 +49,12 @@ const NotificationsPushover = () => {
|
||||
const { data: soundsData } = useSWR<PushoverSound[]>(
|
||||
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({
|
||||
|
||||
@@ -320,12 +320,14 @@ const SettingsMetadata = () => {
|
||||
|
||||
addToast(intl.formatMessage(messages.metadataSettingsSaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} catch (e) {
|
||||
addToast(
|
||||
intl.formatMessage(messages.failedToSaveMetadataSettings),
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -422,6 +424,7 @@ const SettingsMetadata = () => {
|
||||
),
|
||||
{
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ const messages = defineMessages('components.Settings.SettingsNetwork', {
|
||||
proxyBypassFilterTip:
|
||||
"Use ',' as a separator, and '*.' as a wildcard for subdomains",
|
||||
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',
|
||||
networkDisclaimer:
|
||||
'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');
|
||||
|
||||
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', {
|
||||
is: (proxyEnabled: boolean) => proxyEnabled,
|
||||
then: Yup.number().required(
|
||||
@@ -120,8 +136,8 @@ const SettingsNetwork = () => {
|
||||
trustProxy: values.trustProxy,
|
||||
dnsCache: {
|
||||
enabled: values.dnsCacheEnabled,
|
||||
forceMinTtl: values.dnsCacheForceMinTtl,
|
||||
forceMaxTtl: values.dnsCacheForceMaxTtl,
|
||||
forceMinTtl: Number(values.dnsCacheForceMinTtl),
|
||||
forceMaxTtl: Number(values.dnsCacheForceMaxTtl),
|
||||
},
|
||||
proxy: {
|
||||
enabled: values.proxyEnabled,
|
||||
@@ -281,7 +297,7 @@ const SettingsNetwork = () => {
|
||||
<Field
|
||||
id="dnsCacheForceMinTtl"
|
||||
name="dnsCacheForceMinTtl"
|
||||
type="text"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
{errors.dnsCacheForceMinTtl &&
|
||||
@@ -305,7 +321,7 @@ const SettingsNetwork = () => {
|
||||
<Field
|
||||
id="dnsCacheForceMaxTtl"
|
||||
name="dnsCacheForceMaxTtl"
|
||||
type="text"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
{errors.dnsCacheForceMaxTtl &&
|
||||
@@ -375,7 +391,7 @@ const SettingsNetwork = () => {
|
||||
<Field
|
||||
id="proxyPort"
|
||||
name="proxyPort"
|
||||
type="text"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
{errors.proxyPort &&
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -139,7 +139,11 @@ const StatusBadge = ({
|
||||
<div
|
||||
className={`
|
||||
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
|
||||
`}
|
||||
style={{
|
||||
@@ -373,11 +377,66 @@ const StatusBadge = ({
|
||||
|
||||
case MediaStatus.DELETED:
|
||||
return (
|
||||
<Tooltip content={mediaLinkDescription}>
|
||||
<Badge badgeType="danger">
|
||||
{intl.formatMessage(is4k ? messages.status4k : messages.status, {
|
||||
status: intl.formatMessage(globalMessages.deleted),
|
||||
<Tooltip
|
||||
content={inProgress ? tooltipContent : mediaLinkDescription}
|
||||
className={`${
|
||||
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>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1022,6 +1022,8 @@
|
||||
"components.Settings.SettingsNetwork.toastSettingsSuccess": "Settings saved successfully!",
|
||||
"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.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.SettingsUsers.atLeastOneAuth": "At least one authentication method must be selected.",
|
||||
"components.Settings.SettingsUsers.defaultPermissions": "Default Permissions",
|
||||
|
||||
@@ -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