Compare commits
10 Commits
pr-2273
...
preview-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46a7d8804 | ||
|
|
844f86d41d | ||
|
|
d9aceee3f6 | ||
|
|
d0f029b46e | ||
|
|
e0a81038cd | ||
|
|
4ab919360a | ||
|
|
adbcf80333 | ||
|
|
f91a26befe | ||
|
|
0c95b5ec91 | ||
|
|
193d4dc668 |
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">
|
||||||
|
|||||||
@@ -666,6 +666,16 @@ class AvailabilitySync {
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
let existsInRadarr = false;
|
let existsInRadarr = false;
|
||||||
|
|
||||||
|
if (is4k && media.status4k === MediaStatus.AVAILABLE) {
|
||||||
|
logger.debug(
|
||||||
|
`Checking if 4K movie [TMDB ID ${media.tmdbId}] exists in Radarr`,
|
||||||
|
{
|
||||||
|
label: 'AvailabilitySync',
|
||||||
|
externalServiceId4k: media.externalServiceId4k,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check for availability in all of the available radarr servers
|
// Check for availability in all of the available radarr servers
|
||||||
// If any find the media, we will assume the media exists
|
// If any find the media, we will assume the media exists
|
||||||
for (const server of this.radarrServers.filter(
|
for (const server of this.radarrServers.filter(
|
||||||
@@ -870,6 +880,32 @@ class AvailabilitySync {
|
|||||||
this.plexSeasonsCache[ratingKey4k] =
|
this.plexSeasonsCache[ratingKey4k] =
|
||||||
await this.plexClient?.getChildrenMetadata(ratingKey4k);
|
await this.plexClient?.getChildrenMetadata(ratingKey4k);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (plexMedia) {
|
||||||
|
if (media.mediaType === 'movie') {
|
||||||
|
const has4kByWidth = plexMedia.Media?.some(
|
||||||
|
(mediaItem) => (mediaItem.width ?? 0) >= 2000
|
||||||
|
);
|
||||||
|
|
||||||
|
if (is4k) {
|
||||||
|
if (ratingKey === ratingKey4k || !has4kByWidth) {
|
||||||
|
plexMedia = undefined;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const hasNon4kByWidth = plexMedia.Media?.some(
|
||||||
|
(mediaItem) =>
|
||||||
|
(mediaItem.width ?? 0) < 2000 && (mediaItem.width ?? 0) > 0
|
||||||
|
);
|
||||||
|
if (!hasNon4kByWidth && has4kByWidth) {
|
||||||
|
plexMedia = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (media.mediaType === 'tv' && is4k) {
|
||||||
|
if (ratingKey === ratingKey4k) {
|
||||||
|
plexMedia = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (plexMedia) {
|
if (plexMedia) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ export class AddUniqueConstraintToPushSubscription1765233385034
|
|||||||
name = 'AddUniqueConstraintToPushSubscription1765233385034';
|
name = 'AddUniqueConstraintToPushSubscription1765233385034';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
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(
|
await queryRunner.query(
|
||||||
`ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId")`
|
`ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId")`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ export class AddUniqueConstraintToPushSubscription1765233385034
|
|||||||
name = 'AddUniqueConstraintToPushSubscription1765233385034';
|
name = 'AddUniqueConstraintToPushSubscription1765233385034';
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
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(
|
await queryRunner.query(
|
||||||
`CREATE UNIQUE INDEX "UQ_6427d07d9a171a3a1ab87480005" ON "user_push_subscription" ("endpoint", "userId")`
|
`CREATE UNIQUE INDEX "UQ_6427d07d9a171a3a1ab87480005" ON "user_push_subscription" ("endpoint", "userId")`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user